Compare commits
68 Commits
v0.2026031
...
v0.2026032
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8954a6f7 | ||
|
|
99f5716508 | ||
|
|
b8f29c9530 | ||
|
|
33ae00a558 | ||
|
|
ac02de88e6 | ||
|
|
fb8208cfa0 | ||
|
|
66e29b70b8 | ||
|
|
d1b358f76a | ||
|
|
c2ce67c300 | ||
|
|
07522e984c | ||
|
|
36f43c1ae0 | ||
|
|
1b478d0fc1 | ||
|
|
8646be6b8d | ||
|
|
fd9b4fe5e7 | ||
|
|
d52365882c | ||
|
|
135fe62d21 | ||
|
|
99aee0f67b | ||
|
|
a369b3d93e | ||
|
|
4e88cf71b1 | ||
|
|
35bbe221b8 | ||
|
|
05dca72ef3 | ||
|
|
57a66e4788 | ||
|
|
7b0b31455b | ||
|
|
7c16ebd065 | ||
|
|
5df4a7a3ad | ||
|
|
ca41008850 | ||
|
|
badbf141dd | ||
|
|
bc55231baa | ||
|
|
24da33e7cd | ||
|
|
004502ae76 | ||
|
|
d4b0cd5cc2 | ||
|
|
975931ec6d | ||
|
|
748bf732a1 | ||
|
|
70ab40538f | ||
|
|
2ca49b8526 | ||
|
|
11c04f0b31 | ||
|
|
e1e489fbc7 | ||
|
|
e5177f5905 | ||
|
|
210f2f6916 | ||
|
|
9b92cf40c4 | ||
|
|
e8ace69bb0 | ||
|
|
8cfe7de66a | ||
|
|
47a11df4a4 | ||
|
|
27dd42810a | ||
|
|
7eaa284d4e | ||
|
|
c559188f5a | ||
|
|
9a17e3ad24 | ||
|
|
e3d3331bbc | ||
|
|
888ceb256d | ||
|
|
4600d0038b | ||
|
|
6cf889df6c | ||
|
|
c85e3b4f2f | ||
|
|
ecd941c54e | ||
|
|
e1c545220f | ||
|
|
e6b3278fea | ||
|
|
76de05e9b3 | ||
|
|
f7ab10222a | ||
|
|
b5a54c7f16 | ||
|
|
414cb12c7d | ||
|
|
3a24178759 | ||
|
|
e01403ea10 | ||
|
|
a0874b2232 | ||
|
|
a1688f8488 | ||
|
|
56b923c650 | ||
|
|
dc6edd74af | ||
|
|
50dec4bcf3 | ||
|
|
a808880e32 | ||
|
|
1e3ddb231c |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -85,13 +85,13 @@ jobs:
|
||||
# comments on PRs). This step enforces a minimum floor on the
|
||||
# project-wide Hotspot Code Health score (weighted avg of the most
|
||||
# frequently edited files — the ones that matter most).
|
||||
# Current baseline: 9.33 | Aspirational target: 9.5
|
||||
- name: Hotspot Code Health gate (≥9.2)
|
||||
# Current baseline: 9.53 | Aspirational target: 9.8
|
||||
- name: Hotspot Code Health gate (≥9.5)
|
||||
env:
|
||||
CODESCENE_PAT: ${{ secrets.CODESCENE_PAT }}
|
||||
CODESCENE_PROJECT_ID: ${{ secrets.CODESCENE_PROJECT_ID }}
|
||||
run: |
|
||||
THRESHOLD=9.2
|
||||
THRESHOLD=9.5
|
||||
SCORE=$(curl -sf \
|
||||
-H "Authorization: Bearer $CODESCENE_PAT" \
|
||||
-H "Accept: application/json" \
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -8,7 +8,7 @@ pnpm test
|
||||
pnpm test:coverage # frontend ≥70%
|
||||
cargo test
|
||||
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
pre_commit_code_health_safeguard # CodeScene ≥9.2 hotspot + ≥8.8 average
|
||||
pre_commit_code_health_safeguard # CodeScene ≥9.2 hotspot + ≥9.2 average (target: 9.5+)
|
||||
```
|
||||
|
||||
If `pre_commit_code_health_safeguard` fails: extract hooks, split components, reduce complexity. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass the gate.
|
||||
@@ -20,9 +20,9 @@ If `pre_commit_code_health_safeguard` fails: extract hooks, split components, re
|
||||
Write a test in `tests/smoke/<slug>.spec.ts` that covers every acceptance criterion. The test must fail before your fix and pass after. Run it:
|
||||
|
||||
```bash
|
||||
pnpm dev --port <N> &
|
||||
pnpm dev --port 5201 &
|
||||
sleep 3
|
||||
BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
**If your task touches filesystem, git, AI, MCP, or any native Tauri command**: also test with `pnpm tauri dev` against `~/Laputa` (not demo vault). Use `osascript` keyboard events — no mouse, no `cliclick`.
|
||||
@@ -31,9 +31,17 @@ BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
Brian installs the release build and runs keyboard-only QA. Phase 1 must pass first or the task goes to To Rework.
|
||||
|
||||
Fire done signal only after Phase 1 passes:
|
||||
Fire done signal only after Phase 1 passes — **two steps, both required**:
|
||||
|
||||
```bash
|
||||
openclaw system event --text "laputa-task-done:<task_id>:<slug>" --mode now
|
||||
# 1. Move task to In Review on Todoist
|
||||
curl -s -X POST "https://api.todoist.com/api/v1/tasks/<task_id>/move" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"section_id": "6g3XjX33FF4Vj86M"}'
|
||||
|
||||
# 2. Notify Brian
|
||||
openclaw system event --text "laputa-task-done:<task_id>" --mode now
|
||||
```
|
||||
|
||||
## Project
|
||||
@@ -55,6 +63,8 @@ Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with
|
||||
|
||||
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write a failing regression test first, then fix. Exception: pure CSS/layout with no logic.
|
||||
|
||||
**Test quality (Kent Beck's Desiderata):** every test must be Isolated (no shared state), Deterministic (no flakiness), Fast, Behavioral (tests behavior not implementation), Structure-insensitive (refactoring doesn't break it), Specific (failure points to exact cause), Predictive (all pass = production-ready). Fix flaky/non-deterministic tests before adding new ones. E2E tests over unit tests for user flows.
|
||||
|
||||
## ⛔ Docs — Keep docs/ in sync
|
||||
|
||||
After adding a Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit. Use Mermaid for diagrams (not ASCII). Exception: spatial wireframe layouts.
|
||||
|
||||
6
demo-vault-v2/renamed-title-xyz.md
Normal file
6
demo-vault-v2/renamed-title-xyz.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Renamed Title XYZ
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
# Renamed Title XYZ
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Untitled Career Tracks Depend on Company Shape 2
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
# Untitled Career Tracks Depend on Company Shape 2
|
||||
@@ -14,6 +14,7 @@ These frontmatter field names have special meaning in Laputa's UI:
|
||||
|
||||
| Field | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| `title:` | Human-readable title (synced with filename) | Tab label, breadcrumb, sidebar. Filename = `slugify(title).md` |
|
||||
| `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 |
|
||||
@@ -210,9 +211,14 @@ This enables arbitrary, extensible relationship types without code changes.
|
||||
|
||||
All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs.
|
||||
|
||||
### Title Extraction
|
||||
### Title / Filename Sync
|
||||
|
||||
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. Logic in `vault/parsing.rs:extract_title()`.
|
||||
Every note has a `title` field in frontmatter that stores the human-readable title. The filename is always the slug of the title (`slugify(title).md`). The two are kept in sync:
|
||||
|
||||
- **Source of truth**: filename (on open), user input (on rename inside Laputa)
|
||||
- **`extract_title`** reads `title` from frontmatter; falls back to deriving a title from the filename via `slug_to_title()` (hyphens → spaces, title-case). Never reads from H1. Logic in `vault/parsing.rs`.
|
||||
- **On note open** (`sync_title_on_open`): if `title` frontmatter is absent or desynced from the filename, it is auto-corrected (filename wins). Logic in `vault/title_sync.rs`.
|
||||
- **On rename** (`rename_note`): updates both `title` frontmatter and filename atomically, plus wikilinks across the vault. Always writes `title` to frontmatter.
|
||||
|
||||
### Title Field (UI)
|
||||
|
||||
@@ -336,6 +342,10 @@ interface PulseCommit {
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
@@ -344,6 +354,9 @@ interface PulseCommit {
|
||||
- **Git history**: Shown in Inspector panel for active note
|
||||
- **Commit dialog**: Triggered from sidebar or Cmd+K
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
|
||||
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
|
||||
- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes
|
||||
|
||||
## BlockNote Customization
|
||||
|
||||
@@ -526,7 +539,7 @@ Managed by `useIndexing` hook:
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault settings stored in `config/ui.config.md`:
|
||||
Per-vault settings stored in `ui.config.md` at vault root:
|
||||
- Editable as a normal note (YAML frontmatter)
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes
|
||||
|
||||
@@ -164,6 +164,24 @@ flowchart TD
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
|
||||
|
||||
**Triggers:**
|
||||
- `Cmd+Shift+Click` on any note in the note list or sidebar
|
||||
- `Cmd+K` → "Open in New Window" (command palette, requires active note)
|
||||
- `Cmd+Shift+O` keyboard shortcut
|
||||
- Note → "Open in New Window" menu bar item
|
||||
|
||||
**Architecture:**
|
||||
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command)
|
||||
- Secondary windows are sized 800×700 with overlay title bar
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
## AI System
|
||||
|
||||
Laputa has two AI interfaces with distinct architectures:
|
||||
@@ -448,7 +466,7 @@ Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
|
||||
Per-vault UI settings stored in `ui.config.md` at vault root (YAML frontmatter in a markdown note):
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `editor_mode`: "raw" | "preview" (persists across tab switches and sessions)
|
||||
@@ -547,17 +565,34 @@ flowchart LR
|
||||
flowchart TD
|
||||
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
|
||||
PULL --> PC{Result?}
|
||||
PC -->|Conflicts| CM["ConflictResolverModal"]
|
||||
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
|
||||
PC -->|Fast-forward| RV["reload vault"]
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
|
||||
AS --> PUSH["invoke('git_push')"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
|
||||
GC --> GP["invoke('git_push')"]
|
||||
GP --> RM["Reload modified files"]
|
||||
GP --> PR{Push result?}
|
||||
PR -->|ok| RM["Reload modified files"]
|
||||
PR -->|rejected| DIV["syncStatus = pull_required"]
|
||||
DIV -->|User clicks badge| PAP["pullAndPush()"]
|
||||
PAP --> PULL2["invoke('git_pull')"]
|
||||
PULL2 --> GP2["invoke('git_push')"]
|
||||
GP2 --> RM
|
||||
|
||||
CMD["Cmd+K → Pull\nor Menu → Pull"] --> PULL
|
||||
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
|
||||
```
|
||||
|
||||
#### Sync States
|
||||
|
||||
| State | Indicator | Color | Trigger |
|
||||
|-------|-----------|-------|---------|
|
||||
| `idle` | Synced / Synced Xm ago | green | Successful sync |
|
||||
| `syncing` | Syncing... | blue | Pull/push in progress |
|
||||
| `pull_required` | Pull required | orange | Push rejected (divergence) |
|
||||
| `conflict` | Conflict | orange | Merge conflicts detected |
|
||||
| `error` | Sync failed | grey | Network/auth error |
|
||||
|
||||
## Vault Module Structure
|
||||
|
||||
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
@@ -565,10 +600,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction |
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` |
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title`, `slug_to_title` |
|
||||
| `title_sync.rs` | `sync_title_on_open` — ensures `title` frontmatter matches filename on note open |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
|
||||
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
|
||||
@@ -594,7 +630,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
## Tauri IPC Commands (64 total)
|
||||
## Tauri IPC Commands (65 total)
|
||||
|
||||
### Vault Operations
|
||||
|
||||
@@ -604,7 +640,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Move note to trash |
|
||||
| `rename_note` | Rename note + update cross-vault wikilinks |
|
||||
| `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks |
|
||||
| `sync_note_title` | Sync `title` frontmatter with filename on note open → `bool` (modified) |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_trash_notes` | Trash multiple notes |
|
||||
| `batch_delete_notes` | Permanently delete notes from disk |
|
||||
@@ -629,6 +666,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `git_commit` | Stage all + commit |
|
||||
| `git_pull` | Pull from remote |
|
||||
| `git_push` | Push to remote |
|
||||
| `git_remote_status` | Get branch name + ahead/behind counts |
|
||||
| `git_resolve_conflict` | Resolve a merge conflict |
|
||||
| `git_commit_conflict_resolution` | Commit conflict resolution |
|
||||
| `get_file_history` | Last N commits for a file |
|
||||
@@ -673,7 +711,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `create_vault_theme` | Create markdown theme note |
|
||||
| `ensure_vault_themes` | Seed default themes if missing |
|
||||
| `restore_default_themes` | Restore all default themes |
|
||||
| `repair_vault` | Restore default themes + missing config files |
|
||||
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore themes + config |
|
||||
|
||||
### AI & MCP
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unicode-emoji-json": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -143,6 +143,9 @@ importers:
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
unicode-emoji-json:
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.1
|
||||
@@ -3852,6 +3855,9 @@ packages:
|
||||
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unicode-emoji-json@0.8.0:
|
||||
resolution: {integrity: sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
@@ -8264,6 +8270,8 @@ snapshots:
|
||||
|
||||
undici@7.22.0: {}
|
||||
|
||||
unicode-emoji-json@0.8.0: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
61
prompt.txt
Normal file
61
prompt.txt
Normal file
@@ -0,0 +1,61 @@
|
||||
Title/filename sync: derive title from filename, keep in sync via frontmatter
|
||||
|
||||
## Goal
|
||||
Every note has a `title` field in frontmatter that stores the human-readable title with original casing and spacing. The filename is always the slug of the title. The two are kept in sync at all times within Laputa.
|
||||
|
||||
## Current state
|
||||
`extract_title` reads the first H1 heading as the display title, with filename as fallback. This approach is fragile: H1 can diverge from the filename, and using filename directly loses casing information.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
### Title/filename contract
|
||||
- `title` frontmatter = human-readable title (e.g. `Career Tracks Depend on Company Shape`)
|
||||
- filename = slug of title (e.g. `career-tracks-depend-on-company-shape.md`)
|
||||
- The two are always kept in sync within Laputa
|
||||
|
||||
### On note open (sync check)
|
||||
When Laputa opens a note, it checks for desync:
|
||||
- If `title` frontmatter is absent → derive title from filename (hyphens → spaces), write it to frontmatter
|
||||
- If `title` frontmatter is present but its slug doesn't match the filename → **filename wins**: overwrite `title` with the filename-derived value
|
||||
- If both are in sync → no-op
|
||||
|
||||
This means: edits made outside Laputa that change only the filename or only the title frontmatter are auto-corrected on next open. **Filename is the source of truth.**
|
||||
|
||||
### On title edit (inside Laputa)
|
||||
When the user renames a note inside Laputa:
|
||||
- Update `title` frontmatter to the new value
|
||||
- Rename the file to the new slug
|
||||
- Update all wikilinks in the vault (existing rename flow)
|
||||
|
||||
### Title display
|
||||
`extract_title` should read `title` from frontmatter (never from H1). H1 in the body is just content.
|
||||
|
||||
## Keyboard access
|
||||
- No new keyboard interactions — title editing is part of the existing rename flow
|
||||
- QA: Cmd+P → open note → verify title in header matches frontmatter `title` field
|
||||
|
||||
## Edge cases
|
||||
1. Note has no `title` frontmatter → derive from filename on open (silent, idempotent)
|
||||
2. `title` frontmatter desync from filename (edited outside Laputa) → filename wins, title overwritten
|
||||
3. Filename has hyphens that are actual content (e.g. `e2e-test.md`) → becomes `E2e Test` — acceptable tradeoff
|
||||
4. Fresh note created in Laputa → title set immediately from user input, filename = slug
|
||||
5. Note renamed outside Laputa (filename changed, title not) → title updated to match new filename on next open
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Every note has `title` in frontmatter after being opened in Laputa
|
||||
- [ ] Filename = slug of `title` at all times within Laputa
|
||||
- [ ] On open: desync is detected and resolved (filename wins)
|
||||
- [ ] `extract_title` reads from frontmatter `title`, never H1
|
||||
- [ ] Rename flow updates both `title` frontmatter and filename atomically
|
||||
- [ ] **Keyboard access:** open note → rename via title field → file renamed + wikilinks updated
|
||||
- [ ] No regressions (`pnpm test` + `cargo test` pass)
|
||||
- [ ] **Native app QA (keyboard only):** Open note → edit title → confirm filename changed + wikilinks updated; then manually desync title in a file → reopen in Laputa → confirm title auto-corrected
|
||||
|
||||
---
|
||||
CONTEXT:
|
||||
- Worktree: /Users/luca/Workspace/laputa-worktrees/title-filename-sync
|
||||
- Dev port: 5393 (use --port 5393 when running pnpm dev)
|
||||
- Run pnpm test before finishing
|
||||
- Push directly to main when done: git push origin HEAD:main — NEVER open PRs, NEVER --no-verify
|
||||
- Finish signal: openclaw system event --text 'laputa-task-done:6g9hCFpjP7qRRhqv:title-filename-sync' --mode now
|
||||
- See CLAUDE.md for all coding guidelines
|
||||
@@ -3,11 +3,16 @@
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
"main",
|
||||
"note-*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
|
||||
@@ -6,7 +6,8 @@ use crate::claude_cli::{
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{
|
||||
GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit,
|
||||
GitCommit, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile,
|
||||
PulseCommit,
|
||||
};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
@@ -154,10 +155,14 @@ pub fn get_default_vault_path() -> Result<String, 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()))
|
||||
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
vault::invalidate_cache(std::path::Path::new(&path));
|
||||
vault::scan_vault_cached(std::path::Path::new(&path))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -166,6 +171,16 @@ pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
|
||||
vault::reload_entry(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
/// Sync the `title` frontmatter field with the filename on note open.
|
||||
/// Returns `true` if the file was modified (title was absent or desynced).
|
||||
#[tauri::command]
|
||||
pub fn sync_note_title(path: String) -> Result<bool, String> {
|
||||
use vault::SyncAction;
|
||||
let path = expand_tilde(&path);
|
||||
let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?;
|
||||
Ok(matches!(action, SyncAction::Updated { .. }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -282,9 +297,11 @@ pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_pull(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -316,9 +333,19 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_push(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_remote_status(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
// ── GitHub commands ─────────────────────────────────────────────────────────
|
||||
@@ -552,9 +579,17 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
#[tauri::command]
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
// Migrate legacy is_a/Is A frontmatter → type
|
||||
vault::migrate_is_a_to_type(&vault_path)?;
|
||||
// Flatten vault: move notes from type-based subfolders to root
|
||||
vault::flatten_vault(&vault_path)?;
|
||||
// Remove legacy _themes/ directory (JSON theme store) if only defaults remain
|
||||
theme::migrate_legacy_themes_dir(&vault_path);
|
||||
// Migrate legacy theme/ directory to root, then repair themes
|
||||
theme::migrate_theme_dir_to_root(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)?;
|
||||
// Migrate legacy config/ui.config.md → root ui.config.md
|
||||
vault_config::migrate_ui_config_to_root(&vault_path);
|
||||
// Repair config files (AGENTS.md at root, config.md type def)
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
// Ensure .gitignore with sensible defaults exists
|
||||
@@ -704,15 +739,15 @@ mod tests {
|
||||
#[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 note = dir.path().join("test.md");
|
||||
std::fs::write(¬e, "---\ntitle: Test\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();
|
||||
std::fs::write(¬e, "---\ntitle: Test\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()));
|
||||
}
|
||||
@@ -771,7 +806,9 @@ mod tests {
|
||||
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();
|
||||
let vault_str = vault.to_str().unwrap();
|
||||
vault::invalidate_cache(std::path::Path::new(vault_str));
|
||||
let fresh = vault::scan_vault_cached(std::path::Path::new(vault_str)).unwrap();
|
||||
assert!(
|
||||
fresh[0].trashed,
|
||||
"reload_vault must reflect disk state after trashing"
|
||||
@@ -788,4 +825,45 @@ mod tests {
|
||||
let result = get_default_vault_path();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_vault_flattens_type_folders() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let note_dir = vault.join("note");
|
||||
std::fs::create_dir_all(¬e_dir).unwrap();
|
||||
std::fs::write(note_dir.join("hello.md"), "---\nis_a: Note\n---\n# Hello\n").unwrap();
|
||||
|
||||
let result = repair_vault(vault.to_str().unwrap().to_string());
|
||||
assert!(result.is_ok());
|
||||
// Note moved from note/ subfolder to root
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(!note_dir.join("hello.md").exists());
|
||||
// Legacy is_a migrated to type
|
||||
let content = std::fs::read_to_string(vault.join("hello.md")).unwrap();
|
||||
assert!(content.contains("type: Note"));
|
||||
assert!(!content.contains("is_a:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_vault_creates_config_and_theme_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
let result = repair_vault(vault.to_str().unwrap().to_string());
|
||||
assert!(result.is_ok());
|
||||
// Config files at root
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(vault.join("config.md").exists());
|
||||
// Theme files at root (flat structure)
|
||||
assert!(vault.join("default-theme.md").exists());
|
||||
assert!(vault.join("dark-theme.md").exists());
|
||||
assert!(vault.join("minimal-theme.md").exists());
|
||||
assert!(vault.join("theme.md").exists());
|
||||
// No type/themes subfolders
|
||||
assert!(!vault.join("theme").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
// .gitignore
|
||||
assert!(vault.join(".gitignore").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ pub use conflict::{
|
||||
};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
|
||||
pub use remote::{
|
||||
git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult,
|
||||
GitRemoteStatus,
|
||||
};
|
||||
pub use status::{get_modified_files, ModifiedFile};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -110,6 +110,75 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitRemoteStatus {
|
||||
pub branch: String,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
#[serde(rename = "hasRemote")]
|
||||
pub has_remote: bool,
|
||||
}
|
||||
|
||||
/// Get the current branch name, and how many commits ahead/behind the upstream.
|
||||
pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
let branch = current_branch(vault)?;
|
||||
return Ok(GitRemoteStatus {
|
||||
branch,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_remote: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch latest remote refs (silent, best-effort)
|
||||
let _ = Command::new("git")
|
||||
.args(["fetch", "--quiet"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rev-list: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// No upstream set — report 0/0
|
||||
return Ok(GitRemoteStatus {
|
||||
branch,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_remote: true,
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = stdout.trim().split('\t').collect();
|
||||
let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
|
||||
Ok(GitRemoteStatus {
|
||||
branch,
|
||||
ahead,
|
||||
behind,
|
||||
has_remote: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to get branch: {}", e))?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
@@ -391,6 +460,90 @@ hint: have locally."#;
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let status = git_remote_status(vp).unwrap();
|
||||
assert!(!status.has_remote);
|
||||
assert_eq!(status.ahead, 0);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_up_to_date() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
let status = git_remote_status(vp_a).unwrap();
|
||||
assert!(status.has_remote);
|
||||
assert_eq!(status.ahead, 0);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_ahead() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// Make a new commit without pushing
|
||||
fs::write(clone_a.path().join("note.md"), "# Updated\n").unwrap();
|
||||
git_commit(vp_a, "update").unwrap();
|
||||
|
||||
let status = git_remote_status(vp_a).unwrap();
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_behind() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
fs::write(clone_b.path().join("note.md"), "# B update\n").unwrap();
|
||||
git_commit(vp_b, "from B").unwrap();
|
||||
git_push(vp_b).unwrap();
|
||||
|
||||
// A is now behind by 1
|
||||
let status = git_remote_status(vp_a).unwrap();
|
||||
assert_eq!(status.behind, 1);
|
||||
assert_eq!(status.ahead, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_serialization() {
|
||||
let status = GitRemoteStatus {
|
||||
branch: "main".to_string(),
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
has_remote: true,
|
||||
};
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("\"hasRemote\""));
|
||||
let parsed: GitRemoteStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.branch, "main");
|
||||
assert_eq!(parsed.ahead, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
|
||||
@@ -44,13 +44,15 @@ fn run_startup_tasks() {
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
);
|
||||
// Migrate legacy config/ui.config.md → root ui.config.md
|
||||
vault_config::migrate_ui_config_to_root(vp_str);
|
||||
log_startup_result(
|
||||
"Migrated hidden_sections to visible property",
|
||||
vault_config::migrate_hidden_sections_to_visible(vp_str),
|
||||
);
|
||||
|
||||
// Seed _themes/ with built-in JSON themes (legacy) if missing
|
||||
theme::seed_default_themes(vp_str);
|
||||
// Remove legacy _themes/ directory (JSON theme store) if only defaults remain
|
||||
theme::migrate_legacy_themes_dir(vp_str);
|
||||
// Migrate legacy theme/ directory notes to root (flat structure)
|
||||
theme::migrate_theme_dir_to_root(vp_str);
|
||||
// Seed vault theme notes at root (flat structure) if missing
|
||||
@@ -130,6 +132,7 @@ pub fn run() {
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
@@ -140,6 +143,7 @@ pub fn run() {
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
|
||||
@@ -36,10 +36,12 @@ const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
const GO_INBOX: &str = "go-inbox";
|
||||
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const NOTE_EMPTY_TRASH: &str = "note-empty-trash";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
|
||||
const VAULT_OPEN: &str = "vault-open";
|
||||
const VAULT_REMOVE: &str = "vault-remove";
|
||||
@@ -47,6 +49,7 @@ const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started";
|
||||
const VAULT_NEW_THEME: &str = "vault-new-theme";
|
||||
const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes";
|
||||
const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
|
||||
const VAULT_PULL: &str = "vault-pull";
|
||||
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
|
||||
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
|
||||
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
|
||||
@@ -86,12 +89,14 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
VAULT_NEW_THEME,
|
||||
VAULT_RESTORE_DEFAULT_THEMES,
|
||||
VAULT_COMMIT_PUSH,
|
||||
VAULT_PULL,
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
@@ -109,6 +114,7 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_TOGGLE_BACKLINKS,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
];
|
||||
|
||||
/// IDs of menu items that depend on having uncommitted changes.
|
||||
@@ -267,6 +273,7 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?;
|
||||
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
|
||||
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
|
||||
let go_back = MenuItemBuilder::new("Go Back")
|
||||
.id(VIEW_GO_BACK)
|
||||
.accelerator("CmdOrCtrl+[")
|
||||
@@ -281,6 +288,7 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
.item(&inbox)
|
||||
.separator()
|
||||
.item(&go_back)
|
||||
.item(&go_forward)
|
||||
@@ -299,6 +307,10 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
let empty_trash = MenuItemBuilder::new("Empty Trash…")
|
||||
.id(NOTE_EMPTY_TRASH)
|
||||
.build(app)?;
|
||||
let open_new_window = MenuItemBuilder::new("Open in New Window")
|
||||
.id(NOTE_OPEN_IN_NEW_WINDOW)
|
||||
.accelerator("CmdOrCtrl+Shift+O")
|
||||
.build(app)?;
|
||||
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
|
||||
.id(EDIT_TOGGLE_RAW_EDITOR)
|
||||
.accelerator("CmdOrCtrl+\\")
|
||||
@@ -316,6 +328,8 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.item(&trash_note)
|
||||
.item(&empty_trash)
|
||||
.separator()
|
||||
.item(&open_new_window)
|
||||
.separator()
|
||||
.item(&toggle_raw_editor)
|
||||
.item(&toggle_ai_chat)
|
||||
.item(&toggle_backlinks)
|
||||
@@ -341,6 +355,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
let commit_push = MenuItemBuilder::new("Commit & Push")
|
||||
.id(VAULT_COMMIT_PUSH)
|
||||
.build(app)?;
|
||||
let pull = MenuItemBuilder::new("Pull from Remote")
|
||||
.id(VAULT_PULL)
|
||||
.build(app)?;
|
||||
let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts")
|
||||
.id(VAULT_RESOLVE_CONFLICTS)
|
||||
.enabled(false)
|
||||
@@ -370,6 +387,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&restore_default_themes)
|
||||
.separator()
|
||||
.item(&commit_push)
|
||||
.item(&pull)
|
||||
.item(&resolve_conflicts)
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
@@ -485,12 +503,14 @@ mod tests {
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
VAULT_NEW_THEME,
|
||||
VAULT_RESTORE_DEFAULT_THEMES,
|
||||
VAULT_COMMIT_PUSH,
|
||||
VAULT_PULL,
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
|
||||
@@ -21,10 +21,12 @@ pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String
|
||||
|
||||
/// Create a new theme file by copying the active theme (or default).
|
||||
/// Returns the ID of the new theme.
|
||||
/// NOTE: Legacy — operates on `_themes/` JSON store. Prefer `create_vault_theme`.
|
||||
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
if !themes_dir.is_dir() {
|
||||
return Err("Legacy _themes/ directory not found — use vault themes instead".to_string());
|
||||
}
|
||||
|
||||
let new_id = find_available_stem(&themes_dir, "untitled", "json");
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::path::Path;
|
||||
pub use create::{create_theme, create_vault_theme};
|
||||
pub use defaults::*;
|
||||
pub use seed::{
|
||||
ensure_theme_type_definition, ensure_vault_themes, migrate_theme_dir_to_root,
|
||||
restore_default_themes, seed_default_themes, seed_vault_themes,
|
||||
ensure_theme_type_definition, ensure_vault_themes, migrate_legacy_themes_dir,
|
||||
migrate_theme_dir_to_root, restore_default_themes, seed_vault_themes,
|
||||
};
|
||||
|
||||
/// A theme file parsed from _themes/*.json in the vault.
|
||||
@@ -38,13 +38,10 @@ pub struct VaultSettings {
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
/// List all theme files in _themes/ directory of the vault.
|
||||
/// Seeds built-in themes if the directory is missing.
|
||||
/// List all theme files in _themes/ directory of the vault (legacy).
|
||||
/// Returns an empty list if the directory doesn't exist.
|
||||
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
if !themes_dir.is_dir() {
|
||||
seed_default_themes(vault_path);
|
||||
}
|
||||
if !themes_dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -153,16 +150,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_seeds_defaults_when_no_dir() {
|
||||
fn test_list_themes_returns_empty_when_no_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("empty-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let themes = list_themes(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(themes.len(), 3);
|
||||
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"Default"));
|
||||
assert!(names.contains(&"Dark"));
|
||||
assert!(names.contains(&"Minimal"));
|
||||
assert!(themes.is_empty(), "must return empty when _themes/ absent");
|
||||
assert!(!vault.join("_themes").exists(), "must not create _themes/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,34 +3,6 @@ use std::path::Path;
|
||||
|
||||
use super::defaults::*;
|
||||
|
||||
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
|
||||
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
|
||||
if dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
if fs::create_dir_all(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
for (name, content) in files {
|
||||
let _ = fs::write(dir.join(name), content);
|
||||
}
|
||||
log::info!("{log_msg}");
|
||||
}
|
||||
|
||||
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
pub fn seed_default_themes(vault_path: &str) {
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("_themes"),
|
||||
&[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
],
|
||||
"Seeded _themes/ with built-in themes",
|
||||
);
|
||||
}
|
||||
|
||||
/// Write a vault theme file if it doesn't exist or is empty (corrupt).
|
||||
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
|
||||
@@ -85,24 +57,11 @@ pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore default themes for a vault: seeds both `_themes/` (JSON) and
|
||||
/// vault root theme notes (flat structure). Per-file idempotent — never
|
||||
/// Restore default themes for a vault: seeds vault root theme notes (flat
|
||||
/// structure) and the theme.md type definition. Per-file idempotent — never
|
||||
/// overwrites files that already have content. Returns an error on read-only
|
||||
/// filesystems.
|
||||
pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
|
||||
// Seed _themes/ JSON files (per-file idempotent)
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
let json_defaults: &[(&str, &str)] = &[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
];
|
||||
for (name, content) in json_defaults {
|
||||
write_if_missing(&themes_dir.join(name), content)?;
|
||||
}
|
||||
|
||||
// Seed vault theme notes at root (flat structure)
|
||||
ensure_vault_themes(vault_path)?;
|
||||
|
||||
@@ -164,6 +123,38 @@ pub fn migrate_theme_dir_to_root(vault_path: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the legacy `_themes/` directory if it only contains default JSON files.
|
||||
/// Leaves the directory intact if it has any custom (non-default) files.
|
||||
/// Idempotent and silent.
|
||||
pub fn migrate_legacy_themes_dir(vault_path: &str) {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
if !themes_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let default_filenames: &[&str] = &["default.json", "dark.json", "minimal.json"];
|
||||
|
||||
// Check if directory only has default files (or is empty)
|
||||
let has_custom = fs::read_dir(&themes_dir).is_ok_and(|entries| {
|
||||
entries.filter_map(|e| e.ok()).any(|e| {
|
||||
let name = e.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
!default_filenames.contains(&name_str.as_ref())
|
||||
})
|
||||
});
|
||||
|
||||
if has_custom {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove default JSON files then the empty directory
|
||||
for name in default_filenames {
|
||||
let _ = fs::remove_file(themes_dir.join(name));
|
||||
}
|
||||
let _ = fs::remove_dir(&themes_dir);
|
||||
log::info!("Removed legacy _themes/ directory");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -291,10 +282,8 @@ mod tests {
|
||||
|
||||
let msg = restore_default_themes(vp).unwrap();
|
||||
assert_eq!(msg, "Default themes restored");
|
||||
// _themes/ JSON files (legacy)
|
||||
assert!(vault.join("_themes").join("default.json").exists());
|
||||
assert!(vault.join("_themes").join("dark.json").exists());
|
||||
assert!(vault.join("_themes").join("minimal.json").exists());
|
||||
// Must NOT create _themes/ directory (legacy)
|
||||
assert!(!vault.join("_themes").exists());
|
||||
// Vault theme notes at root (flat structure)
|
||||
assert!(vault.join("default-theme.md").exists());
|
||||
assert!(vault.join("dark-theme.md").exists());
|
||||
@@ -366,15 +355,13 @@ mod tests {
|
||||
fn test_restore_default_themes_fills_partial_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("default-theme.md"), &default_vault_theme()).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
assert!(themes_dir.join("dark.json").exists());
|
||||
assert!(themes_dir.join("minimal.json").exists());
|
||||
// Must NOT create _themes/ directory
|
||||
assert!(!vault.join("_themes").exists());
|
||||
assert!(vault.join("dark-theme.md").exists());
|
||||
assert!(vault.join("minimal-theme.md").exists());
|
||||
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
|
||||
@@ -500,4 +487,68 @@ mod tests {
|
||||
assert!(theme_dir.join("custom-theme.md").exists());
|
||||
assert!(theme_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_legacy_themes_dir_removes_defaults_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
|
||||
fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
migrate_legacy_themes_dir(vp);
|
||||
|
||||
assert!(
|
||||
!themes_dir.exists(),
|
||||
"_themes/ must be removed when only defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_legacy_themes_dir_keeps_custom_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(themes_dir.join("custom.json"), r#"{"name":"Custom"}"#).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
migrate_legacy_themes_dir(vp);
|
||||
|
||||
assert!(
|
||||
themes_dir.exists(),
|
||||
"_themes/ must be kept when custom files present"
|
||||
);
|
||||
assert!(themes_dir.join("default.json").exists());
|
||||
assert!(themes_dir.join("custom.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_legacy_themes_dir_noop_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
migrate_legacy_themes_dir(vp);
|
||||
|
||||
assert!(!vault.join("_themes").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_legacy_themes_dir_removes_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
migrate_legacy_themes_dir(vp);
|
||||
|
||||
assert!(!themes_dir.exists(), "empty _themes/ must be removed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,7 +676,11 @@ mod tests {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"existing.md",
|
||||
"---\ntitle: Existing\n---\n# Existing\n",
|
||||
);
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache
|
||||
@@ -686,13 +690,13 @@ mod tests {
|
||||
// Create files in a new protected subdirectory (simulates asset creation)
|
||||
create_test_file(
|
||||
vault,
|
||||
"assets/default.md",
|
||||
"---\nIs A: Theme\n---\n# Default Theme\n",
|
||||
"assets/default-theme.md",
|
||||
"---\ntitle: Default Theme\nIs A: Theme\n---\n# Default Theme\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"assets/dark.md",
|
||||
"---\nIs A: Theme\n---\n# Dark Theme\n",
|
||||
"assets/dark-theme.md",
|
||||
"---\ntitle: Dark Theme\nIs A: Theme\n---\n# Dark Theme\n",
|
||||
);
|
||||
|
||||
// Cache same commit — files in new subdirectory must appear
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::getting_started::AGENTS_MD;
|
||||
/// Content for `config.md` — gives the Config type a sidebar icon and label.
|
||||
const CONFIG_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
Is A: Type
|
||||
type: Type
|
||||
icon: gear-six
|
||||
color: gray
|
||||
order: 90
|
||||
@@ -174,7 +174,7 @@ mod tests {
|
||||
|
||||
assert!(vault.join("config.md").exists());
|
||||
let content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
assert!(content.contains("Is A: Type"));
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ pub struct VaultEntry {
|
||||
#[serde(rename = "relatedTo")]
|
||||
pub related_to: Vec<String>,
|
||||
pub status: Option<String>,
|
||||
pub owner: Option<String>,
|
||||
pub cadence: Option<String>,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
#[serde(rename = "trashedAt")]
|
||||
|
||||
@@ -2,8 +2,9 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
/// Read file metadata (modified_at timestamp, file size).
|
||||
pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option<u64>, u64), String> {
|
||||
/// Read file metadata (modified_at timestamp, created_at timestamp, file size).
|
||||
/// Creation time is sourced from filesystem metadata (birthtime on macOS).
|
||||
pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option<u64>, Option<u64>, u64), String> {
|
||||
let metadata =
|
||||
fs::metadata(path).map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
|
||||
let modified_at = metadata
|
||||
@@ -11,7 +12,12 @@ pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option<u64>, u64), Stri
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
Ok((modified_at, metadata.len()))
|
||||
let created_at = metadata
|
||||
.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
Ok((modified_at, created_at, metadata.len()))
|
||||
}
|
||||
|
||||
/// Read the content of a single note file.
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
use crate::vault::parsing::{contains_wikilink, parse_iso_date};
|
||||
use crate::vault::parsing::contains_wikilink;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub(crate) struct Frontmatter {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
|
||||
pub is_a: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub aliases: Option<StringOrList>,
|
||||
#[serde(rename = "Belongs to")]
|
||||
pub belongs_to: Option<StringOrList>,
|
||||
#[serde(rename = "Related to")]
|
||||
pub related_to: Option<StringOrList>,
|
||||
#[serde(rename = "Status")]
|
||||
pub status: Option<String>,
|
||||
#[serde(rename = "Owner")]
|
||||
pub owner: Option<StringOrList>,
|
||||
#[serde(rename = "Cadence")]
|
||||
pub cadence: Option<StringOrList>,
|
||||
#[serde(
|
||||
rename = "Archived",
|
||||
alias = "archived",
|
||||
@@ -33,26 +25,24 @@ pub(crate) struct Frontmatter {
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub trashed: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(rename = "Trashed at", alias = "trashed_at")]
|
||||
pub trashed_at: Option<String>,
|
||||
#[serde(rename = "Created at")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(rename = "Created time")]
|
||||
pub created_time: Option<String>,
|
||||
pub trashed_at: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
pub icon: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
pub color: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
pub sidebar_label: Option<String>,
|
||||
pub sidebar_label: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
pub template: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
pub sort: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub view: Option<String>,
|
||||
pub view: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
}
|
||||
@@ -122,32 +112,37 @@ impl StringOrList {
|
||||
StringOrList::List(v) => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize to a single scalar: unwrap single-element arrays, take first
|
||||
/// element of multi-element arrays, return scalar unchanged, empty array → None.
|
||||
pub fn into_scalar(self) -> Option<String> {
|
||||
match self {
|
||||
StringOrList::Single(s) => Some(s),
|
||||
StringOrList::List(mut v) => {
|
||||
if v.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(v.swap_remove(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse frontmatter from raw YAML data extracted by gray_matter.
|
||||
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
// Convert HashMap to serde_json::Value for deserialization.
|
||||
// Filter to only known Frontmatter keys to prevent unknown fields with
|
||||
// unexpected types (e.g. a list where a string is expected) from causing
|
||||
// the entire deserialization to fail and return Default (all None).
|
||||
static KNOWN_KEYS: &[&str] = &[
|
||||
"title",
|
||||
"type",
|
||||
"Is A",
|
||||
"is_a",
|
||||
"aliases",
|
||||
"Belongs to",
|
||||
"Related to",
|
||||
"Status",
|
||||
"Owner",
|
||||
"Cadence",
|
||||
"Archived",
|
||||
"archived",
|
||||
"Trashed",
|
||||
"trashed",
|
||||
"Trashed at",
|
||||
"trashed_at",
|
||||
"Created at",
|
||||
"Created time",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
@@ -157,6 +152,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"view",
|
||||
"visible",
|
||||
"notion_id",
|
||||
"Status",
|
||||
"status",
|
||||
];
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = data
|
||||
.iter()
|
||||
@@ -169,17 +166,15 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
|
||||
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
|
||||
/// Only skip keys that can never contain wikilinks.
|
||||
/// Note: owner and cadence are NOT skipped — they should appear in generic properties.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"title",
|
||||
"is a",
|
||||
"type",
|
||||
"aliases",
|
||||
"status",
|
||||
"cadence",
|
||||
"archived",
|
||||
"trashed",
|
||||
"trashed at",
|
||||
"created at",
|
||||
"created time",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
@@ -188,19 +183,16 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"sort",
|
||||
"view",
|
||||
"visible",
|
||||
"status",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
/// Returns a HashMap where each key is the original frontmatter field name
|
||||
/// and the value is a Vec of wikilink strings found in that field.
|
||||
/// Handles both single string values and arrays of strings.
|
||||
fn extract_relationships(
|
||||
pub(crate) fn extract_relationships(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, Vec<String>> {
|
||||
let mut relationships = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
// Skip known non-relationship keys
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) {
|
||||
continue;
|
||||
}
|
||||
@@ -229,26 +221,15 @@ fn extract_relationships(
|
||||
relationships
|
||||
}
|
||||
|
||||
/// Additional keys to skip when extracting custom properties.
|
||||
/// These are already first-class fields on VaultEntry, so including them
|
||||
/// in `properties` would duplicate information.
|
||||
const PROPERTY_EXTRA_SKIP: &[&str] = &["belongs to", "related to", "owner"];
|
||||
|
||||
/// Extract custom scalar properties from raw YAML frontmatter.
|
||||
/// Captures string, number, and boolean values that are not structural fields
|
||||
/// and do not contain wikilinks. Arrays and objects are excluded.
|
||||
fn extract_properties(
|
||||
pub(crate) fn extract_properties(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower))
|
||||
|| PROPERTY_EXTRA_SKIP
|
||||
.iter()
|
||||
.any(|k| k.eq_ignore_ascii_case(&lower))
|
||||
{
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -261,6 +242,17 @@ fn extract_properties(
|
||||
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
// Handle single-element arrays: unwrap to scalar.
|
||||
// This ensures YAML like "Owner: [Luca]" or "Owner:\n - Luca" works correctly.
|
||||
serde_json::Value::Array(arr) => {
|
||||
if arr.len() == 1 {
|
||||
if let Some(serde_json::Value::String(s)) = arr.first() {
|
||||
if !contains_wikilink(s) {
|
||||
properties.insert(key.clone(), serde_json::Value::String(s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -268,20 +260,11 @@ fn extract_properties(
|
||||
properties
|
||||
}
|
||||
|
||||
/// Resolve `is_a` from frontmatter only. Type is determined purely by frontmatter,
|
||||
/// never inferred from folder name.
|
||||
/// Resolve `is_a` from frontmatter only.
|
||||
pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
|
||||
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
|
||||
}
|
||||
|
||||
/// Parse created_at from frontmatter (prefer "Created at" over "Created time").
|
||||
pub(crate) fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
|
||||
fm.created_at
|
||||
.as_ref()
|
||||
.and_then(|s| parse_iso_date(s))
|
||||
.or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s)))
|
||||
}
|
||||
|
||||
/// Convert gray_matter::Pod to serde_json::Value
|
||||
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
match pod {
|
||||
|
||||
@@ -402,18 +402,7 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
|
||||
}
|
||||
|
||||
// Seed built-in themes (both JSON legacy and vault-based markdown notes)
|
||||
let themes_dir = vault_dir.join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
fs::write(themes_dir.join("default.json"), crate::theme::DEFAULT_THEME)
|
||||
.map_err(|e| format!("Failed to write default theme: {e}"))?;
|
||||
fs::write(themes_dir.join("dark.json"), crate::theme::DARK_THEME)
|
||||
.map_err(|e| format!("Failed to write dark theme: {e}"))?;
|
||||
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
|
||||
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
|
||||
|
||||
// Vault theme notes at root (flat structure)
|
||||
// Seed vault theme notes at root (flat structure)
|
||||
fs::write(
|
||||
vault_dir.join("default-theme.md"),
|
||||
crate::theme::default_vault_theme(),
|
||||
@@ -566,10 +555,8 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(
|
||||
entry.title,
|
||||
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
|
||||
);
|
||||
// No frontmatter title → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "AGENTS");
|
||||
// Config files have no frontmatter type field — type is None
|
||||
assert_eq!(entry.is_a, None);
|
||||
}
|
||||
@@ -597,13 +584,8 @@ mod tests {
|
||||
let vault_path = dir.path().join("theme-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
// JSON legacy themes
|
||||
assert!(vault_path.join("_themes/default.json").exists());
|
||||
assert!(vault_path.join("_themes/dark.json").exists());
|
||||
assert!(vault_path.join("_themes/minimal.json").exists());
|
||||
|
||||
let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(themes.len(), 3);
|
||||
// Must NOT create legacy _themes/ directory
|
||||
assert!(!vault_path.join("_themes").exists());
|
||||
|
||||
// Vault-based theme notes at root (flat structure)
|
||||
assert!(vault_path.join("default-theme.md").exists());
|
||||
|
||||
@@ -118,8 +118,8 @@ pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// Folders that are NOT flattened — they contain assets/themes, not notes.
|
||||
const KEEP_FOLDERS: &[&str] = &["attachments", "_themes", "assets"];
|
||||
/// Folders that are NOT flattened — they contain assets, not notes.
|
||||
const KEEP_FOLDERS: &[&str] = &["attachments", "assets"];
|
||||
|
||||
/// Determine a unique filename at `dest_dir`, appending -2, -3, etc. on collision.
|
||||
fn unique_filename(dest_dir: &Path, filename: &str, taken: &HashSet<String>) -> String {
|
||||
@@ -397,7 +397,15 @@ pub fn vault_health_check(vault_path: &str) -> Result<VaultHealthReport, String>
|
||||
Err(_) => continue,
|
||||
};
|
||||
let parsed = matter.parse(&content);
|
||||
let title = super::parsing::extract_title(&parsed.content, &filename);
|
||||
let fm_title = parsed.data.as_ref().and_then(|pod| {
|
||||
if let gray_matter::Pod::Hash(ref map) = pod {
|
||||
if let Some(gray_matter::Pod::String(s)) = map.get("title") {
|
||||
return Some(s.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
let title = super::parsing::extract_title(fm_title, &content, &filename);
|
||||
let expected_stem = slugify(&title);
|
||||
let expected_filename = format!("{}.md", expected_stem);
|
||||
let current_stem = filename.strip_suffix(".md").unwrap_or(&filename);
|
||||
@@ -593,14 +601,12 @@ mod tests {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "attachments/image.md", "# Image note\n");
|
||||
write_nested_file(vault, "_themes/legacy.md", "---\n---\n# Legacy\n");
|
||||
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(vault.join("attachments/image.md").exists());
|
||||
assert!(vault.join("_themes/legacy.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -740,11 +746,11 @@ mod tests {
|
||||
fn test_health_check_detects_title_mismatch() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
// Filename is "wrong-name.md" but title is "My Actual Title"
|
||||
// Filename is "wrong-name.md" but title frontmatter says "My Actual Title"
|
||||
write_file(
|
||||
vault,
|
||||
"wrong-name.md",
|
||||
"---\ntype: Note\n---\n# My Actual Title\n",
|
||||
"---\ntitle: My Actual Title\ntype: Note\n---\n# My Actual Title\n",
|
||||
);
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,20 +23,20 @@ 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.",
|
||||
"my-note.md",
|
||||
"---\ntitle: My Note\nStatus: Active\n---\n# My Note\n\nOriginal.",
|
||||
);
|
||||
let entry = reload_entry(&dir.path().join("note.md")).unwrap();
|
||||
let entry = reload_entry(&dir.path().join("my-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.",
|
||||
"my-note.md",
|
||||
"---\ntitle: My Note\nStatus: Done\n---\n# My Note\n\nUpdated.",
|
||||
);
|
||||
let fresh = reload_entry(&dir.path().join("note.md")).unwrap();
|
||||
let fresh = reload_entry(&dir.path().join("my-note.md")).unwrap();
|
||||
assert_eq!(fresh.status, Some("Done".to_string()));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ fn test_reload_entry_nonexistent_file() {
|
||||
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";
|
||||
const FULL_FM_CONTENT: &str = "---\ntitle: Laputa Project\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]
|
||||
fn test_parse_full_frontmatter_identity() {
|
||||
@@ -63,8 +63,12 @@ fn test_parse_full_frontmatter_lists() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
|
||||
assert_eq!(entry.aliases, vec!["Laputa", "Castle in the Sky"]);
|
||||
assert_eq!(entry.belongs_to, vec!["Studio Ghibli"]);
|
||||
assert_eq!(entry.related_to, vec!["Miyazaki"]);
|
||||
// Belongs to / Related to are no longer first-class fields.
|
||||
// As arrays of plain strings (no wikilinks), they don't appear in
|
||||
// relationships or properties — only wikilink arrays become relationships,
|
||||
// and only scalars become properties.
|
||||
assert!(entry.relationships.get("Belongs to").is_none());
|
||||
assert!(entry.relationships.get("Related to").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -72,8 +76,14 @@ fn test_parse_full_frontmatter_scalars() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
assert_eq!(entry.owner, Some("Luca".to_string()));
|
||||
assert_eq!(entry.cadence, Some("Weekly".to_string()));
|
||||
assert_eq!(
|
||||
entry.properties.get("Owner").and_then(|v| v.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
assert_eq!(
|
||||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||||
Some("Weekly")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -81,10 +91,11 @@ fn test_parse_empty_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"empty-fm.md",
|
||||
"just-a-title.md",
|
||||
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
|
||||
);
|
||||
assert_eq!(entry.title, "Just a Title");
|
||||
// No title in frontmatter → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "Just A Title");
|
||||
assert!(entry.aliases.is_empty());
|
||||
|
||||
assert!(entry.belongs_to.is_empty());
|
||||
@@ -95,11 +106,11 @@ fn test_parse_empty_frontmatter() {
|
||||
fn test_parse_no_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "# A Note Without Frontmatter\n\nJust markdown.";
|
||||
create_test_file(dir.path(), "no-fm.md", content);
|
||||
create_test_file(dir.path(), "a-note-without-frontmatter.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("no-fm.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md")).unwrap();
|
||||
// No title in frontmatter → derived from filename
|
||||
assert_eq!(entry.title, "A Note Without Frontmatter");
|
||||
// is_a is inferred from parent folder name (temp dir), not None
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -157,12 +168,11 @@ fn test_scan_vault_skips_non_protected_subfolders() {
|
||||
fn test_scan_vault_includes_all_protected_folders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "root.md", "# Root\n");
|
||||
create_test_file(dir.path(), "_themes/legacy.md", "---\n---\n# Legacy\n");
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "assets/image.md", "# Asset\n");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_eq!(entries.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -294,17 +304,19 @@ Belongs to:
|
||||
create_test_file(dir.path(), "some-project.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap();
|
||||
// Owner contains a wikilink, so it should appear in relationships
|
||||
|
||||
// Owner with wikilink should appear in relationships
|
||||
assert!(entry.relationships.get("Owner").is_some());
|
||||
// Wikilinks don't go to properties
|
||||
assert!(entry.properties.get("Owner").is_none());
|
||||
|
||||
// Belongs to is a wikilink array, should appear in relationships
|
||||
let belongs = entry.relationships.get("Belongs to").unwrap();
|
||||
assert_eq!(
|
||||
entry.relationships.get("Owner").unwrap(),
|
||||
&vec!["[[person/luca-rossi|Luca Rossi]]".to_string()]
|
||||
);
|
||||
// Belongs to is also a wikilink array, should appear in relationships
|
||||
assert_eq!(
|
||||
entry.relationships.get("Belongs to").unwrap(),
|
||||
belongs,
|
||||
&vec!["[[responsibility/grow-newsletter]]".to_string()]
|
||||
);
|
||||
// Still parsed in the dedicated field too
|
||||
// Also parsed in the dedicated field
|
||||
assert_eq!(entry.belongs_to, vec!["[[responsibility/grow-newsletter]]"]);
|
||||
}
|
||||
|
||||
@@ -352,8 +364,8 @@ fn test_parse_relationships_custom_fields() {
|
||||
fn test_parse_relationships_owner_and_notes() {
|
||||
let rels = parse_big_project_rels();
|
||||
assert_eq!(rels.get("Notes").unwrap().len(), 3);
|
||||
// Owner is now a structural field (skipped from relationships)
|
||||
assert!(rels.get("Owner").is_none());
|
||||
// Owner with wikilink should be in relationships
|
||||
assert!(rels.get("Owner").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -427,9 +439,9 @@ fn test_skip_keys_identity_fields_excluded() {
|
||||
#[test]
|
||||
fn test_skip_keys_temporal_fields_excluded() {
|
||||
let (rels, _) = parse_skip_keys_rels();
|
||||
assert!(rels.get("Cadence").is_none());
|
||||
assert!(rels.get("Created at").is_none());
|
||||
assert!(rels.get("Created time").is_none());
|
||||
assert!(rels.get("Cadence").is_some());
|
||||
assert!(rels.get("Created at").is_some());
|
||||
assert!(rels.get("Created time").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -439,9 +451,12 @@ fn test_skip_keys_real_relation_included() {
|
||||
rels.get("Real Relation").unwrap(),
|
||||
&vec!["[[note/important]]".to_string()]
|
||||
);
|
||||
// "Real Relation" + auto-generated "Type" (from is_a: "[[project]]")
|
||||
assert_eq!(len, 2);
|
||||
// "Real Relation" + "Type" + "Cadence" + "Created at" + "Created time"
|
||||
assert_eq!(len, 5);
|
||||
assert_eq!(rels.get("Type").unwrap(), &vec!["[[project]]".to_string()]);
|
||||
assert!(rels.get("Cadence").is_some());
|
||||
assert!(rels.get("Created at").is_some());
|
||||
assert!(rels.get("Created time").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -471,6 +486,79 @@ References:
|
||||
);
|
||||
}
|
||||
|
||||
// --- large relationship array (regression: No Code note with 32 Notes) ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_large_notes_relationship_array() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
type: Topic
|
||||
Referred by Data:
|
||||
- "[[michele-sampieri|Michele Sampieri]]"
|
||||
- "[[varun-anand|Varun Anand]]"
|
||||
Belongs to:
|
||||
- "[[engineering|Engineering]]"
|
||||
aliases:
|
||||
- No Code
|
||||
Notes:
|
||||
- "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]"
|
||||
- "[[airdev-build-hub|Airdev Build Hub]]"
|
||||
- "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]"
|
||||
- "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]"
|
||||
- "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]"
|
||||
- "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]"
|
||||
- "[[chameleon-microsurveys|Chameleon | Microsurveys]]"
|
||||
- "[[felt-the-best-way-to-make-maps-on-the-internet|Felt – The best way to make maps on the internet]]"
|
||||
- "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]"
|
||||
- "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]"
|
||||
- "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]"
|
||||
- "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]"
|
||||
- "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]"
|
||||
- "[[michele-sampieri|Michele Sampieri]]"
|
||||
- "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]"
|
||||
- "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]"
|
||||
- "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]"
|
||||
- "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]"
|
||||
- "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]"
|
||||
- "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]"
|
||||
- "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]"
|
||||
- "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]"
|
||||
- "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]"
|
||||
- "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]"
|
||||
- "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]"
|
||||
- "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]"
|
||||
- "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]"
|
||||
- "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]"
|
||||
- "[[varun-anand|Varun Anand]]"
|
||||
- "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]"
|
||||
- "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]"
|
||||
- "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]"
|
||||
title: No Code
|
||||
---
|
||||
# No Code
|
||||
"#;
|
||||
create_test_file(dir.path(), "no-code.md", content);
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap();
|
||||
|
||||
let notes = entry
|
||||
.relationships
|
||||
.get("Notes")
|
||||
.expect("Notes relationship should exist");
|
||||
assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed");
|
||||
|
||||
let referred = entry
|
||||
.relationships
|
||||
.get("Referred by Data")
|
||||
.expect("Referred by Data should exist");
|
||||
assert_eq!(referred.len(), 2);
|
||||
|
||||
let belongs = entry
|
||||
.relationships
|
||||
.get("Belongs to")
|
||||
.expect("Belongs to should exist");
|
||||
assert_eq!(belongs.len(), 1);
|
||||
}
|
||||
|
||||
// --- type from frontmatter only (no folder inference) ---
|
||||
|
||||
#[test]
|
||||
@@ -489,26 +577,20 @@ fn test_no_type_when_frontmatter_missing() {
|
||||
assert_eq!(entry.is_a, None, "type should not be inferred from folder");
|
||||
}
|
||||
|
||||
// --- created_at parsing from frontmatter ---
|
||||
// --- created_at sourcing from filesystem ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_created_at_from_frontmatter() {
|
||||
fn test_created_at_from_filesystem() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nCreated at: 2025-05-23T14:35:00.000Z\n---\n# Test\n";
|
||||
let content = "---\nIs A: Note\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
assert_eq!(entry.created_at, Some(1748010900));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_created_time_fallback() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nCreated time: 2025-05-23\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
assert_eq!(entry.created_at, Some(1747958400));
|
||||
// created_at should be set from filesystem metadata (not None)
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
"created_at should come from filesystem"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Type relationship tests ---
|
||||
@@ -799,12 +881,20 @@ Priority: High
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "project/test.md", content);
|
||||
// Only Priority should survive — all others are structural
|
||||
assert_eq!(entry.properties.len(), 1);
|
||||
// Priority + Owner + Cadence should survive — all others are structural
|
||||
assert_eq!(entry.properties.len(), 3);
|
||||
assert_eq!(
|
||||
entry.properties.get("Priority").and_then(|v| v.as_str()),
|
||||
Some("High")
|
||||
);
|
||||
assert_eq!(
|
||||
entry.properties.get("Owner").and_then(|v| v.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
assert_eq!(
|
||||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||||
Some("Weekly")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1048,6 +1138,79 @@ fn test_roundtrip_is_a_snake_case_alias_still_works() {
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
|
||||
// --- StringOrList normalization (uniform, no per-field special cases) ---
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_owner_unwraps_to_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Responsibility\nOwner:\n - Luca\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(
|
||||
entry.properties.get("Owner").and_then(|v| v.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Responsibility".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_cadence_unwraps_to_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Procedure\nCadence:\n - Weekly\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(
|
||||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||||
Some("Weekly")
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Procedure".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_status_unwraps_to_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Project\nStatus:\n - Active\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
assert_eq!(entry.is_a, Some("Project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scalar_fields_unchanged() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Project\nOwner: Luca\nCadence: Daily\nStatus: Done\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(
|
||||
entry.properties.get("Owner").and_then(|v| v.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
assert_eq!(
|
||||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||||
Some("Daily")
|
||||
);
|
||||
assert_eq!(entry.status, Some("Done".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_absent_fields_no_crash() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(entry.status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_field_does_not_break_type_detection() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Responsibility\nOwner:\n - Luca\nCadence:\n - Weekly\nStatus:\n - Active\n---\n# My Responsibility\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Responsibility".to_string()),
|
||||
"type must not be lost when other fields are arrays"
|
||||
);
|
||||
|
||||
assert_eq!(entry.status, Some("Active".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
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
//! Pure text-processing helpers for markdown content parsing.
|
||||
//! Snippet extraction, markdown stripping, date parsing, and string utilities.
|
||||
|
||||
/// Extract the title from a markdown file's content.
|
||||
/// Tries the first H1 heading (`# Title`), falls back to filename without extension.
|
||||
pub(super) fn extract_title(content: &str, filename: &str) -> String {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(heading) = trimmed.strip_prefix("# ") {
|
||||
let title = heading.trim();
|
||||
if !title.is_empty() {
|
||||
return title.to_string();
|
||||
/// Derive a human-readable title from a filename stem (slug).
|
||||
/// Converts hyphens to spaces and title-cases each word.
|
||||
/// Example: `career-tracks-depend-on-company-shape` → `Career Tracks Depend on Company Shape`
|
||||
pub(super) fn slug_to_title(stem: &str) -> String {
|
||||
stem.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(c) => {
|
||||
let upper: String = c.to_uppercase().collect();
|
||||
format!("{}{}", upper, chars.as_str())
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Extract the display title for a note.
|
||||
/// Priority: frontmatter `title:` → filename-derived title.
|
||||
/// H1 headings are treated as body content, not title source.
|
||||
pub(super) fn extract_title(fm_title: Option<&str>, _content: &str, filename: &str) -> String {
|
||||
if let Some(title) = fm_title {
|
||||
if !title.is_empty() {
|
||||
return title.to_string();
|
||||
}
|
||||
}
|
||||
// Fallback: filename without .md extension
|
||||
filename.strip_suffix(".md").unwrap_or(filename).to_string()
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
slug_to_title(stem)
|
||||
}
|
||||
|
||||
/// Remove YAML frontmatter (triple-dash delimited) from content.
|
||||
@@ -271,27 +288,81 @@ pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- extract_title tests ---
|
||||
// --- slug_to_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_from_h1() {
|
||||
let content = "---\nIs A: Note\n---\n# My Great Note\n\nSome content here.";
|
||||
assert_eq!(extract_title(content, "my-great-note.md"), "My Great Note");
|
||||
fn test_slug_to_title_basic() {
|
||||
assert_eq!(slug_to_title("career-tracks"), "Career Tracks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_fallback_to_filename() {
|
||||
let content = "Just some content without a heading.";
|
||||
fn test_slug_to_title_single_word() {
|
||||
assert_eq!(slug_to_title("hello"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_empty() {
|
||||
assert_eq!(slug_to_title(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_e2e() {
|
||||
assert_eq!(slug_to_title("e2e-test"), "E2e Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_multiple_hyphens() {
|
||||
assert_eq!(slug_to_title("a--b"), "A B");
|
||||
}
|
||||
|
||||
// --- extract_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_from_frontmatter() {
|
||||
assert_eq!(
|
||||
extract_title(content, "fallback-title.md"),
|
||||
"fallback-title"
|
||||
extract_title(Some("My Great Note"), "", "my-great-note.md"),
|
||||
"My Great Note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_empty_h1_falls_back() {
|
||||
let content = "# \n\nSome content.";
|
||||
assert_eq!(extract_title(content, "empty-h1.md"), "empty-h1");
|
||||
fn test_extract_title_ignores_h1_uses_filename() {
|
||||
// H1 is body content, not a title source
|
||||
assert_eq!(
|
||||
extract_title(None, "# Hello World\n\nBody text.", "some-file.md"),
|
||||
"Some File"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_ignores_h1_after_frontmatter() {
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nBody.";
|
||||
assert_eq!(extract_title(None, content, "fallback.md"), "Fallback");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_fallback_to_filename() {
|
||||
assert_eq!(
|
||||
extract_title(None, "", "fallback-title.md"),
|
||||
"Fallback Title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_empty_fm_falls_back_to_filename() {
|
||||
// Empty frontmatter title falls back to filename, not H1
|
||||
assert_eq!(
|
||||
extract_title(Some(""), "# From H1\n", "empty-h1.md"),
|
||||
"Empty H1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_empty_fm_no_h1_falls_back_to_filename() {
|
||||
assert_eq!(
|
||||
extract_title(Some(""), "No heading here.", "empty-h1.md"),
|
||||
"Empty H1"
|
||||
);
|
||||
}
|
||||
|
||||
// --- extract_snippet tests ---
|
||||
|
||||
@@ -16,7 +16,7 @@ pub struct RenameResult {
|
||||
}
|
||||
|
||||
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
|
||||
fn title_to_slug(title: &str) -> String {
|
||||
pub(super) fn title_to_slug(title: &str) -> String {
|
||||
title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
@@ -28,32 +28,6 @@ fn title_to_slug(title: &str) -> String {
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Update the first H1 heading in markdown content to a new title.
|
||||
fn update_h1_title(content: &str, new_title: &str) -> String {
|
||||
let has_h1 = content.lines().any(|l| l.trim().starts_with("# "));
|
||||
if !has_h1 {
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
let result: Vec<String> = content
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim().starts_with("# ") {
|
||||
format!("# {}", new_title)
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let joined = result.join("\n");
|
||||
if content.ends_with('\n') && !joined.ends_with('\n') {
|
||||
format!("{}\n", joined)
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a regex that matches wiki links referencing old title or path stem.
|
||||
fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex> {
|
||||
let pattern_str = format!(
|
||||
@@ -128,33 +102,36 @@ fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Check if frontmatter contains a `title:` key.
|
||||
fn frontmatter_has_title_key(content: &str) -> bool {
|
||||
/// Extract the value of the `title:` frontmatter field from raw content.
|
||||
fn extract_fm_title_value(content: &str) -> Option<String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
content[4..]
|
||||
.split("\n---")
|
||||
.next()
|
||||
.map(|fm| {
|
||||
fm.lines().any(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with("title:") || t.starts_with("\"title\":")
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Update H1 and optionally the `title:` frontmatter field in content.
|
||||
fn update_note_title_in_content(content: &str, new_title: &str) -> String {
|
||||
let mut updated = update_h1_title(content, new_title);
|
||||
if frontmatter_has_title_key(content) {
|
||||
let value = FrontmatterValue::String(new_title.to_string());
|
||||
if let Ok(c) = update_frontmatter_content(&updated, "title", Some(value)) {
|
||||
updated = c;
|
||||
let fm = content[4..].split("\n---").next()?;
|
||||
for line in fm.lines() {
|
||||
let t = line.trim_start();
|
||||
for prefix in &["title:", "\"title\":"] {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let val = rest.trim().trim_matches('"').trim_matches('\'');
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updated
|
||||
None
|
||||
}
|
||||
|
||||
/// Update the `title:` frontmatter field in content.
|
||||
/// Always writes `title` to frontmatter (creates it if absent).
|
||||
/// H1 headings are body content and are NOT modified — the title source
|
||||
/// of truth is frontmatter `title:` → filename, never H1.
|
||||
fn update_note_title_in_content(content: &str, new_title: &str) -> String {
|
||||
let value = FrontmatterValue::String(new_title.to_string());
|
||||
match update_frontmatter_content(content, "title", Some(value)) {
|
||||
Ok(c) => c,
|
||||
Err(_) => content.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
|
||||
@@ -190,12 +167,11 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
|
||||
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
/// the file's H1 heading. This is needed when the caller has already saved updated
|
||||
/// content to disk (e.g. the editor saved a new H1 before triggering the rename)
|
||||
/// so the on-disk H1 already matches `new_title`.
|
||||
/// the file's frontmatter/filename. This is needed when the caller has already saved
|
||||
/// updated content to disk before triggering the rename.
|
||||
pub fn rename_note(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
@@ -219,7 +195,8 @@ pub fn rename_note(
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let extracted_title = super::extract_title(&content, &old_filename);
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let extracted_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let old_title = old_title_hint.unwrap_or(&extracted_title);
|
||||
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
@@ -295,15 +272,6 @@ mod tests {
|
||||
assert_eq!(title_to_slug("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_h1_title() {
|
||||
let content = "---\nIs A: Note\n---\n# Old Title\n\nContent here.\n";
|
||||
let updated = update_h1_title(content, "New Title");
|
||||
assert!(updated.contains("# New Title"));
|
||||
assert!(!updated.contains("# Old Title"));
|
||||
assert!(updated.contains("Content here."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -328,7 +296,9 @@ mod tests {
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
|
||||
let new_content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(new_content.contains("# Sprint Retrospective"));
|
||||
// H1 is body content — rename must NOT modify it
|
||||
assert!(new_content.contains("# Weekly Review"));
|
||||
assert!(new_content.contains("title: Sprint Retrospective"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -451,7 +421,8 @@ mod tests {
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(content.contains("title: New Name"));
|
||||
assert!(content.contains("# New Name"));
|
||||
// H1 is body content — rename must NOT modify it
|
||||
assert!(content.contains("# Old Name"));
|
||||
}
|
||||
|
||||
// --- Regression: rename empty / minimal notes (nota vuota) ---
|
||||
@@ -510,7 +481,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_rename_note_h1_only_no_body() {
|
||||
let content = rename_test_note("note/heading-only.md", "# Old Heading\n", "New Heading");
|
||||
assert!(content.contains("# New Heading"));
|
||||
// H1 is body content — rename must NOT modify it
|
||||
assert!(content.contains("# Old Heading"));
|
||||
assert!(content.contains("title: New Heading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -521,7 +494,8 @@ mod tests {
|
||||
"Renamed Note",
|
||||
);
|
||||
assert!(content.contains("title: Renamed Note"));
|
||||
assert!(content.contains("# Renamed Note"));
|
||||
// H1 is body content — rename must NOT modify it
|
||||
assert!(content.contains("# My Note"));
|
||||
}
|
||||
|
||||
// --- rename-on-save: filename doesn't match title slug ---
|
||||
@@ -675,6 +649,41 @@ mod tests {
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_does_not_modify_h1() {
|
||||
// H1 is body content — rename should only update frontmatter title, not H1
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/old.md",
|
||||
"---\ntitle: Old Title\ntype: Note\n---\n\n# Old Title\n\nSome body text.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/old.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Brand New Title",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(
|
||||
content.contains("title: Brand New Title"),
|
||||
"frontmatter title should be updated"
|
||||
);
|
||||
assert!(
|
||||
content.contains("# Old Title"),
|
||||
"H1 must NOT be modified by rename"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("# Brand New Title"),
|
||||
"H1 must NOT match new title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_hint_same_as_new_title_noop() {
|
||||
// If old_title_hint == new_title, should be a noop
|
||||
|
||||
183
src-tauri/src/vault/title_sync.rs
Normal file
183
src-tauri/src/vault/title_sync.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
use super::parsing::slug_to_title;
|
||||
use super::rename::title_to_slug;
|
||||
|
||||
/// Result of a title sync check.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SyncAction {
|
||||
/// Title and filename are already in sync.
|
||||
InSync,
|
||||
/// Title was absent or desynced; frontmatter was updated on disk.
|
||||
Updated { title: String },
|
||||
}
|
||||
|
||||
/// Extract the raw `title:` value from frontmatter in file content.
|
||||
fn extract_raw_title(content: &str) -> Option<String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return None;
|
||||
}
|
||||
let fm = content[4..].split("\n---").next()?;
|
||||
for line in fm.lines() {
|
||||
let t = line.trim_start();
|
||||
for prefix in &["title:", "\"title\":"] {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let val = rest.trim().trim_matches('"').trim_matches('\'');
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Sync the `title` frontmatter field with the filename.
|
||||
///
|
||||
/// Rules (filename is source of truth):
|
||||
/// - If `title` is absent → derive from filename, write to frontmatter
|
||||
/// - If `title` is present but its slug doesn't match the filename stem → overwrite
|
||||
/// - If both are in sync → no-op
|
||||
pub fn sync_title_on_open(path: &Path) -> Result<SyncAction, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(&filename);
|
||||
let expected_title = slug_to_title(stem);
|
||||
|
||||
let fm_title = extract_raw_title(&content);
|
||||
|
||||
match fm_title {
|
||||
Some(ref title) if title_to_slug(title) == stem => Ok(SyncAction::InSync),
|
||||
_ => {
|
||||
// Title absent or desynced — filename wins
|
||||
let value = FrontmatterValue::String(expected_title.clone());
|
||||
let updated = update_frontmatter_content(&content, "title", Some(value))
|
||||
.map_err(|e| format!("Failed to update frontmatter: {}", e))?;
|
||||
fs::write(path, &updated)
|
||||
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
|
||||
Ok(SyncAction::Updated {
|
||||
title: expected_title,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_note(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_adds_title_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_note(
|
||||
dir.path(),
|
||||
"career-tracks.md",
|
||||
"---\ntype: Note\n---\n# Career Tracks\n",
|
||||
);
|
||||
let result = sync_title_on_open(&path).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
SyncAction::Updated {
|
||||
title: "Career Tracks".to_string()
|
||||
}
|
||||
);
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("title: Career Tracks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_noop_when_in_sync() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_note(
|
||||
dir.path(),
|
||||
"my-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n# My Note\n",
|
||||
);
|
||||
let result = sync_title_on_open(&path).unwrap();
|
||||
assert_eq!(result, SyncAction::InSync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_overwrites_desynced_title() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Filename says "new-name" but title says "Old Name"
|
||||
let path = write_note(
|
||||
dir.path(),
|
||||
"new-name.md",
|
||||
"---\ntitle: Old Name\ntype: Note\n---\n# Old Name\n",
|
||||
);
|
||||
let result = sync_title_on_open(&path).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
SyncAction::Updated {
|
||||
title: "New Name".to_string()
|
||||
}
|
||||
);
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("title: New Name"));
|
||||
assert!(!content.contains("title: Old Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_adds_frontmatter_when_none_exists() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_note(
|
||||
dir.path(),
|
||||
"plain-note.md",
|
||||
"# Plain Note\n\nSome content.\n",
|
||||
);
|
||||
let result = sync_title_on_open(&path).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
SyncAction::Updated {
|
||||
title: "Plain Note".to_string()
|
||||
}
|
||||
);
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.starts_with("---\n"));
|
||||
assert!(content.contains("title: Plain Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_e2e_filename() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_note(dir.path(), "e2e-test.md", "---\ntype: Note\n---\n");
|
||||
let result = sync_title_on_open(&path).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
SyncAction::Updated {
|
||||
title: "E2e Test".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_preserves_other_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_note(
|
||||
dir.path(),
|
||||
"my-note.md",
|
||||
"---\ntype: Project\nstatus: Active\n---\n# My Note\n",
|
||||
);
|
||||
sync_title_on_open(&path).unwrap();
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("type: Project"));
|
||||
assert!(content.contains("status: Active"));
|
||||
assert!(content.contains("title: My Note"));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Vault-wide UI configuration stored in `config/ui.config.md`.
|
||||
/// Vault-wide UI configuration stored in `ui.config.md` at vault root.
|
||||
///
|
||||
/// This file is a regular vault note with YAML frontmatter, visible in the
|
||||
/// sidebar under the "Config" section and editable like any note.
|
||||
@@ -21,14 +21,13 @@ pub struct VaultConfig {
|
||||
pub property_display_modes: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
const CONFIG_DIR: &str = "config";
|
||||
const CONFIG_FILENAME: &str = "ui.config.md";
|
||||
|
||||
fn config_path(vault_path: &str) -> std::path::PathBuf {
|
||||
Path::new(vault_path).join(CONFIG_DIR).join(CONFIG_FILENAME)
|
||||
Path::new(vault_path).join(CONFIG_FILENAME)
|
||||
}
|
||||
|
||||
/// Read the vault-wide UI config from `config/ui.config.md`.
|
||||
/// Read the vault-wide UI config from `ui.config.md` at vault root.
|
||||
/// Returns default values if the file doesn't exist.
|
||||
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
|
||||
let path = config_path(vault_path);
|
||||
@@ -69,19 +68,42 @@ fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
|
||||
.map_err(|e| format!("Failed to parse config: {e}"))
|
||||
}
|
||||
|
||||
/// Save the vault-wide UI config to `config/ui.config.md`.
|
||||
/// Creates the directory and file if they don't exist.
|
||||
/// Save the vault-wide UI config to `ui.config.md` at vault root.
|
||||
/// Creates the file if it doesn't exist.
|
||||
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
|
||||
let path = config_path(vault_path);
|
||||
let dir = Path::new(vault_path).join(CONFIG_DIR);
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
}
|
||||
|
||||
let content = serialize_config(&config);
|
||||
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/ui.config.md` → root `ui.config.md`.
|
||||
/// If the root file already exists, the legacy file is simply removed.
|
||||
/// Cleans up empty `config/` directory after migration.
|
||||
pub fn migrate_ui_config_to_root(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let legacy = vault.join("config").join(CONFIG_FILENAME);
|
||||
let root = vault.join(CONFIG_FILENAME);
|
||||
|
||||
if legacy.exists() {
|
||||
if !root.exists() {
|
||||
// Move legacy content to root
|
||||
if let Ok(content) = std::fs::read_to_string(&legacy) {
|
||||
let _ = std::fs::write(&root, content);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&legacy);
|
||||
}
|
||||
|
||||
// Clean up empty config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
if config_dir.is_dir() {
|
||||
let is_empty = std::fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
|
||||
if is_empty {
|
||||
let _ = std::fs::remove_dir(&config_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
|
||||
fn serialize_config(config: &VaultConfig) -> String {
|
||||
let mut lines = vec!["---".to_string(), "type: config".to_string()];
|
||||
@@ -142,7 +164,7 @@ fn yaml_safe_value(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
|
||||
/// Migrate `hidden_sections` from `ui.config.md` to `visible: false`
|
||||
/// on Type notes. Returns the number of Type notes updated.
|
||||
///
|
||||
/// For each type name in `hidden_sections`:
|
||||
@@ -354,11 +376,9 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
// Create config with hidden_sections at vault root
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -375,7 +395,7 @@ property_display_modes:
|
||||
assert!(recipe.contains("visible: false"));
|
||||
|
||||
// Config should no longer have hidden_sections
|
||||
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
|
||||
let config_content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(!config_content.contains("hidden_sections"));
|
||||
}
|
||||
|
||||
@@ -384,11 +404,9 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
// Create config with hidden_sections at vault root
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -416,10 +434,8 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -440,19 +456,15 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Type note already has visible: false
|
||||
let type_dir = dir.path().join("type");
|
||||
std::fs::create_dir_all(&type_dir).unwrap();
|
||||
// Type note already has visible: false at vault root
|
||||
std::fs::write(
|
||||
type_dir.join("note.md"),
|
||||
dir.path().join("note.md"),
|
||||
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -460,7 +472,7 @@ property_display_modes:
|
||||
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
|
||||
let content = std::fs::read_to_string(dir.path().join("note.md")).unwrap();
|
||||
// Should have exactly one visible: false, not two
|
||||
assert_eq!(content.matches("visible:").count(), 1);
|
||||
}
|
||||
@@ -478,4 +490,90 @@ property_display_modes:
|
||||
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
|
||||
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_moves_legacy_to_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.5\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// Root file should exist with migrated content
|
||||
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(content.contains("zoom: 1.5"));
|
||||
// Legacy file and empty config dir should be gone
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
assert!(!config_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_preserves_existing_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Root already has content
|
||||
std::fs::write(
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 2.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Legacy also exists
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// Root should keep its original content
|
||||
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(content.contains("zoom: 2.0"));
|
||||
// Legacy file should be removed
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_keeps_nonempty_config_dir() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(config_dir.join("ui.config.md"), "---\ntype: config\n---\n").unwrap();
|
||||
std::fs::write(config_dir.join("other.md"), "Other file").unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// config/ should still exist because it has other files
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("other.md").exists());
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_config_writes_to_vault_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let config = VaultConfig {
|
||||
zoom: Some(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
save_vault_config(vault_path, config).unwrap();
|
||||
|
||||
// File should be at vault root, not in config/
|
||||
assert!(dir.path().join("ui.config.md").exists());
|
||||
assert!(!dir.path().join("config").exists());
|
||||
}
|
||||
}
|
||||
|
||||
142
src/App.tsx
142
src/App.tsx
@@ -23,6 +23,7 @@ import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { isEmoji } from './utils/emoji'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
@@ -48,10 +49,11 @@ import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
|
||||
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, VaultEntry } from './types'
|
||||
import type { SidebarSelection, VaultEntry, InboxPeriod } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries } from './utils/noteListHelpers'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { flushEditorContent } from './utils/autoSave'
|
||||
import './App.css'
|
||||
|
||||
@@ -69,6 +71,12 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
|
||||
const handleSetSelection = useCallback((sel: SidebarSelection) => {
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
}, [])
|
||||
const layout = useLayoutPanels()
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
@@ -77,7 +85,7 @@ function App() {
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
// guarantee the latest closure is always used).
|
||||
const vaultSwitcher = useVaultSwitcher({
|
||||
onSwitch: () => { setSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
||||
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
|
||||
@@ -149,11 +157,40 @@ function App() {
|
||||
dialogs.closeConflictResolver()
|
||||
}, [autoSync, dialogs])
|
||||
|
||||
/** Resolve a single file conflict from the in-editor banner. */
|
||||
const handleResolveConflictInline = useCallback(async (filePath: string, strategy: 'ours' | 'theirs') => {
|
||||
try {
|
||||
const relativePath = filePath.replace(resolvedPath + '/', '')
|
||||
const call = isTauri() ? invoke : mockInvoke
|
||||
await call('git_resolve_conflict', { vaultPath: resolvedPath, file: relativePath, strategy })
|
||||
// Reload the note content to show the resolved version
|
||||
const content = await (isTauri() ? invoke<string> : mockInvoke<string>)('get_note_content', { path: filePath })
|
||||
// Check remaining conflicts
|
||||
const remaining = await (isTauri() ? invoke<string[]> : mockInvoke<string[]>)('get_conflict_files', { vaultPath: resolvedPath })
|
||||
if (remaining.length === 0) {
|
||||
// All resolved — auto-commit the merge and push
|
||||
await (isTauri() ? invoke : mockInvoke)('git_commit_conflict_resolution', { vaultPath: resolvedPath })
|
||||
vault.reloadVault()
|
||||
autoSync.triggerSync()
|
||||
setToastMessage('All conflicts resolved — merge committed')
|
||||
} else {
|
||||
void content // content reload happens via vault reload
|
||||
vault.reloadVault()
|
||||
setToastMessage(`Resolved — ${remaining.length} conflict${remaining.length > 1 ? 's' : ''} remaining`)
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to resolve conflict: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, vault, autoSync, setToastMessage])
|
||||
|
||||
const handleKeepMine = useCallback((path: string) => handleResolveConflictInline(path, 'ours'), [handleResolveConflictInline])
|
||||
const handleKeepTheirs = useCallback((path: string) => handleResolveConflictInline(path, 'theirs'), [handleResolveConflictInline])
|
||||
|
||||
// Ref bridges handleContentChange (created after notes) into useNoteActions.
|
||||
// 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, 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 })
|
||||
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, onFrontmatterContentChanged: themeManager.notifyThemeSaved })
|
||||
|
||||
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
@@ -198,7 +235,7 @@ function App() {
|
||||
onOpenNote: openNoteByPath,
|
||||
onOpenTab: openNoteByPath,
|
||||
onSetFilter: (filterType) => {
|
||||
setSelection({ kind: 'sectionGroup', type: filterType })
|
||||
handleSetSelection({ kind: 'sectionGroup', type: filterType })
|
||||
},
|
||||
onVaultChanged: () => { vault.reloadVault() },
|
||||
})
|
||||
@@ -231,6 +268,35 @@ function App() {
|
||||
vault.reloadVault()
|
||||
}, [vault])
|
||||
|
||||
const handleSetNoteIcon = useCallback(async (path: string, emoji: string) => {
|
||||
await notes.handleUpdateFrontmatter(path, 'icon', emoji)
|
||||
}, [notes])
|
||||
|
||||
const handleRemoveNoteIcon = useCallback(async (path: string) => {
|
||||
await notes.handleDeleteProperty(path, 'icon')
|
||||
}, [notes])
|
||||
|
||||
/** Command palette: open the emoji picker on the active note's NoteIcon. */
|
||||
const handleSetNoteIconCommand = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:open-icon-picker'))
|
||||
}, [])
|
||||
|
||||
/** Command palette: remove the active note's emoji icon. */
|
||||
const handleRemoveNoteIconCommand = useCallback(() => {
|
||||
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
|
||||
}, [notes.activeTabPath, handleRemoveNoteIcon])
|
||||
|
||||
/** Open the active note in a new window (command palette / keyboard shortcut). */
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title)
|
||||
}, [notes.tabs, notes.activeTabPath, resolvedPath])
|
||||
|
||||
/** Open a specific note entry in a new window (Cmd+Shift+Click). */
|
||||
const handleOpenEntryInNewWindow = useCallback((entry: VaultEntry) => {
|
||||
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
|
||||
}, [resolvedPath])
|
||||
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
@@ -289,6 +355,22 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
|
||||
/** Flush pending auto-save before closing a tab to prevent data loss. */
|
||||
const handleCloseTabWithFlush = useCallback((path: string) => {
|
||||
savePendingForPath(path).catch(() => {})
|
||||
notes.handleCloseTab(path)
|
||||
}, [savePendingForPath, notes])
|
||||
|
||||
// Wrap the close-tab ref so Cmd+W and menu bar also flush auto-save
|
||||
const closeTabWithFlushRef = useRef<(path: string) => void>(handleCloseTabWithFlush)
|
||||
useEffect(() => {
|
||||
const original = notes.handleCloseTabRef.current
|
||||
closeTabWithFlushRef.current = (path: string) => {
|
||||
savePendingForPath(path).catch(() => {})
|
||||
original(path)
|
||||
}
|
||||
})
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
@@ -309,7 +391,7 @@ function App() {
|
||||
}
|
||||
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
|
||||
|
||||
const entryActions = useEntryActions({
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
@@ -335,10 +417,12 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
/** H1→title sync: save pending content then rename file + update wikilinks. */
|
||||
const handleTitleSync = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
/** Title field change: save pending content then rename file + update wikilinks (non-blocking). */
|
||||
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
||||
savePendingForPath(path)
|
||||
.then(() => notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry))
|
||||
.then(vault.loadModifiedFiles)
|
||||
.catch((err) => console.error('Title rename failed:', err))
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
@@ -400,7 +484,7 @@ function App() {
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
handleCloseTabRef: closeTabWithFlushRef, tabs: notes.tabs,
|
||||
entries: vault.entries,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
@@ -415,6 +499,7 @@ function App() {
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
onResolveConflicts: handleOpenConflictResolver,
|
||||
onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
@@ -422,7 +507,7 @@ function App() {
|
||||
onToggleRawEditor: () => rawToggleRef.current(),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
|
||||
onSelect: handleSetSelection, onCloseTab: notes.handleCloseTab,
|
||||
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
||||
@@ -432,7 +517,7 @@ function App() {
|
||||
onCreateTheme: async () => {
|
||||
const path = await themeManager.createTheme()
|
||||
const freshEntries = await vault.reloadVault()
|
||||
setSelection({ kind: 'sectionGroup', type: 'Theme' })
|
||||
handleSetSelection({ kind: 'sectionGroup', type: 'Theme' })
|
||||
if (path) {
|
||||
const entry = freshEntries.find(e => e.path === path)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
@@ -459,19 +544,31 @@ function App() {
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
onRemoveNoteIcon: handleRemoveNoteIconCommand,
|
||||
activeNoteHasIcon: (() => {
|
||||
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
|
||||
return !!(ae?.icon && isEmoji(ae.icon))
|
||||
})(),
|
||||
noteListFilter,
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
|
||||
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
return filterEntries(vault.entries, selection).map(e => ({
|
||||
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection)
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
}, [vault.entries, selection])
|
||||
}, [vault.entries, selection, inboxPeriod])
|
||||
|
||||
const aiNoteListFilter = useMemo(() => {
|
||||
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
|
||||
if (selection.kind === 'topic') return { type: null, query: selection.entry.title }
|
||||
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
|
||||
return { type: null, query: '' }
|
||||
}, [selection])
|
||||
@@ -492,7 +589,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} inboxCount={inboxCount} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -503,7 +600,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} 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} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} 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} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -515,7 +612,7 @@ function App() {
|
||||
activeTabPath={notes.activeTabPath}
|
||||
entries={vault.entries}
|
||||
onSwitchTab={notes.handleSwitchTab}
|
||||
onCloseTab={notes.handleCloseTab}
|
||||
onCloseTab={handleCloseTabWithFlush}
|
||||
onReorderTabs={notes.handleReorderTabs}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
@@ -558,6 +655,11 @@ function App() {
|
||||
onFileCreated={handleAgentFileCreated}
|
||||
onFileModified={handleAgentFileModified}
|
||||
onVaultChanged={handleAgentVaultChanged}
|
||||
onSetNoteIcon={handleSetNoteIcon}
|
||||
onRemoveNoteIcon={handleRemoveNoteIcon}
|
||||
isConflicted={!!notes.activeTabPath && autoSync.conflictFiles.some(f => notes.activeTabPath?.endsWith(f))}
|
||||
onKeepMine={handleKeepMine}
|
||||
onKeepTheirs={handleKeepTheirs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,7 +675,7 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
|
||||
169
src/NoteWindow.tsx
Normal file
169
src/NoteWindow.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Editor } from './components/Editor'
|
||||
import { Toast } from './components/Toast'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import { getNoteWindowParams } from './utils/windowMode'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import type { VaultEntry } from './types'
|
||||
import './App.css'
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal app shell for secondary "note windows" opened via "Open in New Window".
|
||||
* Shows only the editor — no sidebar, no note list.
|
||||
*/
|
||||
export default function NoteWindow() {
|
||||
const params = getNoteWindowParams()
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const activeTabPath = tabs[0]?.entry.path ?? null
|
||||
|
||||
const layout = useLayoutPanels()
|
||||
|
||||
// Load vault entries + note content on mount
|
||||
useEffect(() => {
|
||||
if (!params) return
|
||||
const { vaultPath, notePath } = params
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
const vaultEntries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
|
||||
if (cancelled) return
|
||||
setEntries(vaultEntries)
|
||||
const entry = vaultEntries.find(e => e.path === notePath)
|
||||
if (!entry) return
|
||||
const content = await tauriCall<string>('get_note_content', { path: notePath })
|
||||
if (cancelled) return
|
||||
setTabs([{ entry, content }])
|
||||
}
|
||||
|
||||
load().catch(err => console.error('NoteWindow load failed:', err))
|
||||
return () => { cancelled = true }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount with captured params
|
||||
|
||||
// Apply theme
|
||||
const vaultPath = params?.vaultPath ?? ''
|
||||
useThemeManager(vaultPath, entries)
|
||||
|
||||
// Update window title when note title changes
|
||||
useEffect(() => {
|
||||
const title = tabs[0]?.entry.title
|
||||
if (!title) return
|
||||
if (!isTauri()) { document.title = title; return }
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().setTitle(title)
|
||||
}).catch(() => {})
|
||||
}, [tabs])
|
||||
|
||||
// Auto-save
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries(prev => prev.map(e => e.path === path ? { ...e, ...patch } : e))
|
||||
setTabs(prev => prev.map(t => t.entry.path === path ? { ...t, entry: { ...t.entry, ...patch } } : t))
|
||||
}, [])
|
||||
|
||||
const onAfterSave = useCallback(() => {}, [])
|
||||
const onNotePersisted = useCallback(() => {}, [])
|
||||
|
||||
const { handleSave, handleContentChange } = useEditorSaveWithLinks({
|
||||
updateEntry,
|
||||
setTabs,
|
||||
setToastMessage,
|
||||
onAfterSave,
|
||||
onNotePersisted,
|
||||
})
|
||||
|
||||
// Wikilink navigation — in a note window, open wikilinks in the same window
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
const targetLower = target.toLowerCase()
|
||||
const entry = entries.find(e =>
|
||||
e.title.toLowerCase() === targetLower ||
|
||||
e.aliases.some(a => a.toLowerCase() === targetLower)
|
||||
)
|
||||
if (!entry) return
|
||||
tauriCall<string>('get_note_content', { path: entry.path }).then(content => {
|
||||
setTabs([{ entry, content }])
|
||||
}).catch(() => {})
|
||||
}, [entries])
|
||||
|
||||
// Stub for close tab — in a note window, close the window
|
||||
const handleCloseTab = useCallback(() => {
|
||||
if (!isTauri()) return
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().close()
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Keyboard: Cmd+S to save, Cmd+W to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
handleCloseTab()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSave, handleCloseTab])
|
||||
|
||||
const activeTab = tabs[0] ?? null
|
||||
const gitHistory = useMemo(() => [], [])
|
||||
|
||||
if (!params) {
|
||||
return <div className="app-shell"><p>Invalid note window parameters</p></div>
|
||||
}
|
||||
|
||||
if (!activeTab) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
|
||||
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
<div className="app__editor">
|
||||
<Editor
|
||||
tabs={tabs}
|
||||
activeTabPath={activeTabPath}
|
||||
entries={entries}
|
||||
onSwitchTab={() => {}}
|
||||
onCloseTab={handleCloseTab}
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
inspectorCollapsed={layout.inspectorCollapsed}
|
||||
onToggleInspector={() => layout.setInspectorCollapsed(c => !c)}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab.entry}
|
||||
inspectorContent={activeTab.content}
|
||||
gitHistory={gitHistory}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
leftPanelsCollapsed={true}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo } from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
@@ -179,7 +180,10 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
<div className="flex items-center gap-1 min-w-0 whitespace-nowrap" style={{ fontSize: 12 }}>
|
||||
<span className="shrink-0 text-muted-foreground">{entry.isA || 'Note'}</span>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 2px' }}>›</span>
|
||||
<span className="truncate font-medium text-foreground" style={{ maxWidth: '40vw' }}>{entry.title}</span>
|
||||
<span className="truncate font-medium text-foreground" style={{ maxWidth: '40vw' }}>
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="shrink-0 text-muted-foreground">{wordCount.toLocaleString()} words</span>
|
||||
{noteStatus === 'pendingSave' && (
|
||||
|
||||
@@ -21,11 +21,11 @@ describe('BulkActionBar', () => {
|
||||
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Restore and Delete permanently in trash view', () => {
|
||||
it('shows Restore, Archive, and Delete permanently in trash view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} />)
|
||||
expect(screen.getByTestId('bulk-restore-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-trash-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -47,4 +47,20 @@ describe('BulkActionBar', () => {
|
||||
render(<BulkActionBar {...defaultProps} count={5} />)
|
||||
expect(screen.getByText('5 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Unarchive and Trash buttons in archived view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isArchivedView={true} />)
|
||||
expect(screen.getByTestId('bulk-unarchive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onUnarchive when Unarchive button clicked in archived view', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isArchivedView={true} onUnarchive={onUnarchive} />)
|
||||
fireEvent.click(screen.getByTestId('bulk-unarchive-btn'))
|
||||
expect(onUnarchive).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,17 +4,61 @@ import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
|
||||
interface BulkActionBarProps {
|
||||
count: number
|
||||
isTrashView: boolean
|
||||
isArchivedView?: boolean
|
||||
onArchive: () => void
|
||||
onTrash: () => void
|
||||
onRestore: () => void
|
||||
onDeletePermanently: () => void
|
||||
onUnarchive?: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const
|
||||
const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const
|
||||
|
||||
function BulkActionBarInner({ count, isTrashView, onArchive, onTrash, onRestore, onDeletePermanently, onClear }: BulkActionBarProps) {
|
||||
function renderTrashActions(onRestore: () => void, onArchive: () => void, onDeletePermanently: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onRestore} title="Restore selected notes" data-testid="bulk-restore-btn">
|
||||
<ArrowCounterClockwise size={14} /> Restore
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
|
||||
<Archive size={14} /> Archive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDeletePermanently} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete permanently
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderArchivedActions(onUnarchive: (() => void) | undefined, onTrash: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onUnarchive} title="Unarchive selected notes" data-testid="bulk-unarchive-btn">
|
||||
<ArrowCounterClockwise size={14} /> Unarchive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onTrash} title="Move selected notes to trash" data-testid="bulk-trash-btn">
|
||||
<Trash size={14} /> Trash
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultActions(onArchive: () => void, onTrash: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
|
||||
<Archive size={14} /> Archive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onTrash} title="Move selected notes to trash" data-testid="bulk-trash-btn">
|
||||
<Trash size={14} /> Trash
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, isTrashView, isArchivedView, onArchive, onTrash, onRestore, onDeletePermanently, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
@@ -30,53 +74,9 @@ function BulkActionBarInner({ count, isTrashView, onArchive, onTrash, onRestore,
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isTrashView ? (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={actionBtnStyle}
|
||||
onClick={onRestore}
|
||||
title="Restore selected notes"
|
||||
data-testid="bulk-restore-btn"
|
||||
>
|
||||
<ArrowCounterClockwise size={14} />
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={destructiveBtnStyle}
|
||||
onClick={onDeletePermanently}
|
||||
title="Permanently delete selected notes"
|
||||
data-testid="bulk-delete-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Delete permanently
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={actionBtnStyle}
|
||||
onClick={onArchive}
|
||||
title="Archive selected notes"
|
||||
data-testid="bulk-archive-btn"
|
||||
>
|
||||
<Archive size={14} />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={destructiveBtnStyle}
|
||||
onClick={onTrash}
|
||||
title="Move selected notes to trash"
|
||||
data-testid="bulk-trash-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Trash
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isTrashView ? renderTrashActions(onRestore, onArchive, onDeletePermanently)
|
||||
: isArchivedView ? renderArchivedActions(onUnarchive, onTrash)
|
||||
: renderDefaultActions(onArchive, onTrash)}
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
|
||||
29
src/components/ConflictNoteBanner.test.tsx
Normal file
29
src/components/ConflictNoteBanner.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConflictNoteBanner } from './ConflictNoteBanner'
|
||||
|
||||
describe('ConflictNoteBanner', () => {
|
||||
it('renders conflict message', () => {
|
||||
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={vi.fn()} />)
|
||||
expect(screen.getByText('This note has a merge conflict')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onKeepMine when clicking Keep mine button', () => {
|
||||
const onKeepMine = vi.fn()
|
||||
render(<ConflictNoteBanner onKeepMine={onKeepMine} onKeepTheirs={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('conflict-keep-mine-btn'))
|
||||
expect(onKeepMine).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onKeepTheirs when clicking Keep theirs button', () => {
|
||||
const onKeepTheirs = vi.fn()
|
||||
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={onKeepTheirs} />)
|
||||
fireEvent.click(screen.getByTestId('conflict-keep-theirs-btn'))
|
||||
expect(onKeepTheirs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('has the correct test id', () => {
|
||||
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={vi.fn()} />)
|
||||
expect(screen.getByTestId('conflict-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
68
src/components/ConflictNoteBanner.tsx
Normal file
68
src/components/ConflictNoteBanner.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface ConflictNoteBannerProps {
|
||||
onKeepMine: () => void
|
||||
onKeepTheirs: () => void
|
||||
}
|
||||
|
||||
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="conflict-note-banner"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 16px',
|
||||
background: 'var(--muted)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
color: 'var(--accent-orange)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
<span>This note has a merge conflict</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
data-testid="conflict-keep-mine-btn"
|
||||
onClick={onKeepMine}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Keep my local version"
|
||||
>
|
||||
Keep mine
|
||||
</button>
|
||||
<button
|
||||
data-testid="conflict-keep-theirs-btn"
|
||||
onClick={onKeepTheirs}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Keep the remote version"
|
||||
>
|
||||
Keep theirs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -227,14 +227,14 @@ describe('DynamicPropertiesPanel', () => {
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ archived: false }}
|
||||
frontmatter={{ published: false }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
// Boolean should show as Yes/No toggle
|
||||
const toggleBtn = screen.getByText('\u2717 No')
|
||||
fireEvent.click(toggleBtn)
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('archived', true)
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('published', true)
|
||||
})
|
||||
|
||||
it('renders array property as tag pills', () => {
|
||||
@@ -441,7 +441,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ archived: 'false' }}
|
||||
frontmatter={{ draft: 'false' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
@@ -450,7 +450,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
const input = screen.getByDisplayValue('false')
|
||||
fireEvent.change(input, { target: { value: 'true' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('archived', true)
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('draft', true)
|
||||
})
|
||||
|
||||
it('coerces numeric strings to numbers on save', () => {
|
||||
@@ -664,6 +664,39 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('property row 50/50 layout', () => {
|
||||
it('uses CSS grid with two equal columns on editable rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ url: 'https://example.com/very/long/path/that/should/be/truncated' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
const editableRows = screen.getAllByTestId('editable-property')
|
||||
editableRows.forEach(row => {
|
||||
expect(row.className).toContain('grid')
|
||||
expect(row.className).toContain('grid-cols-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses CSS grid with two equal columns on read-only rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
readOnlyRows.forEach(row => {
|
||||
expect(row.className).toContain('grid')
|
||||
expect(row.className).toContain('grid-cols-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL property rendering', () => {
|
||||
it('renders URL values with link styling instead of plain EditableValue', () => {
|
||||
render(
|
||||
@@ -885,7 +918,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ archived: false }}
|
||||
frontmatter={{ published: false }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
@@ -894,6 +927,54 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('system property filtering', () => {
|
||||
it('hides trashed, trashed_at, archived, archived_at, icon from properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ trashed: true, trashed_at: '2026-01-01', archived: false, archived_at: '', icon: '📝', cadence: 'Weekly' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('trashed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('trashed_at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('archived_at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('icon')).not.toBeInTheDocument()
|
||||
// Custom property still visible
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters system properties case-insensitively', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Trashed: true, Archived: false, Icon: '🎯', cadence: 'Daily' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not filter similar but non-matching property names', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ 'Is Trashed': true, 'archive_date': '2026-01-01' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Is Trashed')).toBeInTheDocument()
|
||||
expect(screen.getByText('archive_date')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('display mode override', () => {
|
||||
beforeEach(() => {
|
||||
resetVaultConfigStore()
|
||||
|
||||
@@ -47,7 +47,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/prop flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-0.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<span className="truncate">{propKey}</span>
|
||||
{onDelete && (
|
||||
@@ -55,15 +55,17 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
)}
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
</span>
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
<div className="min-w-0">
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="font-mono-overline shrink-0" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="font-mono-overline min-w-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -116,7 +118,7 @@ export function DynamicPropertiesPanel({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
|
||||
@@ -23,11 +23,19 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Scroll area wrapping title + editor — single scroll context for alignment */
|
||||
.editor-scroll-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* BlockNote container */
|
||||
.editor__blocknote-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
@@ -88,7 +96,7 @@
|
||||
.editor__blocknote-container .bn-editor {
|
||||
width: 100%;
|
||||
padding: 20px 40px;
|
||||
max-width: 760px;
|
||||
max-width: var(--editor-max-width, 760px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -162,9 +170,109 @@
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* --- Title Section: wraps icon + title + separator, aligned to editor --- */
|
||||
.title-section {
|
||||
width: 100%;
|
||||
max-width: var(--editor-max-width, 760px);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--editor-padding-horizontal, 40px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-section__separator {
|
||||
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* --- Note Icon Area --- */
|
||||
.note-icon-area {
|
||||
padding: 16px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-icon-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.note-icon-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.note-icon-button--active {
|
||||
font-size: 40px;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.note-icon-button--active:hover:not(:disabled) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.note-icon-button--add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-faint, #999);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.note-icon-area:hover .note-icon-button--add,
|
||||
.note-icon-button--add:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note-icon-button__plus {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.note-icon-button__label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note-icon-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 140px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-dialog, var(--border));
|
||||
background: var(--popover);
|
||||
box-shadow: 0 4px 16px var(--shadow-dialog, rgba(0,0,0,0.1));
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.note-icon-menu__item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.note-icon-menu__item:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.note-icon-menu__item--danger {
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
/* --- Title Field --- */
|
||||
.title-field {
|
||||
padding: 16px 54px 0;
|
||||
padding: 4px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -174,11 +282,13 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--editor-title-size, 28px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-size: var(--headings-h1-font-size);
|
||||
font-weight: var(--headings-h1-font-weight);
|
||||
line-height: var(--headings-h1-line-height);
|
||||
letter-spacing: var(--headings-h1-letter-spacing);
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('Editor', () => {
|
||||
const trashedTab = { entry: trashedEntry, content: mockContent }
|
||||
|
||||
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
|
||||
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
return render(<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
}
|
||||
|
||||
it('shows banner and read-only editor when note is trashed', () => {
|
||||
@@ -336,9 +336,10 @@ describe('Editor', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
|
||||
const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
const updatedTab = { entry: trashedEntryUpdated, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[trashedEntryUpdated]} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
|
||||
@@ -346,13 +347,14 @@ describe('Editor', () => {
|
||||
|
||||
it('removes trash banner immediately when entry is restored (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
|
||||
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
|
||||
const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null }
|
||||
const restoredTab = { entry: restoredEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[restoredEntry]} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
@@ -408,6 +410,7 @@ describe('click empty editor space', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
entries={[trashedEntry]}
|
||||
tabs={[{ entry: trashedEntry, content: mockContent }]}
|
||||
activeTabPath={trashedEntry.path}
|
||||
/>
|
||||
@@ -429,9 +432,10 @@ describe('archived note behavior', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
|
||||
const archivedEntry = { ...mockEntry, archived: true }
|
||||
const archivedTab = { entry: archivedEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
@@ -440,13 +444,14 @@ describe('archived note behavior', () => {
|
||||
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
|
||||
const archivedTab = { entry: archivedEntry, content: mockContent }
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
|
||||
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
|
||||
const unarchivedEntry = { ...archivedEntry, archived: false }
|
||||
const unarchivedTab = { entry: unarchivedEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[unarchivedEntry]} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
|
||||
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
|
||||
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
@@ -59,7 +58,7 @@ interface EditorProps {
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
/** Called when H1→title sync updates the title (debounced). */
|
||||
/** Called when the user edits the title in TitleField. */
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
@@ -74,6 +73,16 @@ interface EditorProps {
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
/** Called when user sets an emoji icon on a note. */
|
||||
onSetNoteIcon?: (path: string, emoji: string) => void
|
||||
/** Called when user removes an emoji icon from a note. */
|
||||
onRemoveNoteIcon?: (path: string) => void
|
||||
/** Whether the active note has a merge conflict. */
|
||||
isConflicted?: boolean
|
||||
/** Resolve conflict by keeping the local version. */
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -121,8 +130,6 @@ interface EditorSetupParams {
|
||||
activeTabPath: string | null
|
||||
vaultPath?: string
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
onLoadDiff?: (path: string) => Promise<string>
|
||||
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
@@ -165,8 +172,8 @@ function useRawModeWithFlush(
|
||||
}
|
||||
|
||||
function useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange, onTitleSync,
|
||||
onRenameTab, onLoadDiff, onLoadDiffAtCommit, getNoteStatus,
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff, onLoadDiffAtCommit, getNoteStatus,
|
||||
rawToggleRef, diffToggleRef,
|
||||
}: EditorSetupParams) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
@@ -179,28 +186,15 @@ function useEditorSetup({
|
||||
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
|
||||
const { syncActiveRef, onH1Changed, onManualRename } = useHeadingTitleSync({
|
||||
activeTabPath,
|
||||
currentTitle: activeTab?.entry.title ?? null,
|
||||
onTitleSync: onTitleSync ?? (() => {}),
|
||||
})
|
||||
|
||||
const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush(
|
||||
activeTabPath, onContentChange,
|
||||
)
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
tabs, activeTabPath, editor, onContentChange,
|
||||
onH1Change: onH1Changed, syncActiveRef, rawMode,
|
||||
tabs, activeTabPath, editor, onContentChange, rawMode,
|
||||
})
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
|
||||
const handleRenameTabWithSync = useCallback((path: string, newTitle: string) => {
|
||||
const h1Text = getH1TextFromBlocks(editor.document)
|
||||
onManualRename(newTitle, h1Text)
|
||||
onRenameTab?.(path, newTitle)
|
||||
}, [editor, onManualRename, onRenameTab])
|
||||
|
||||
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
})
|
||||
@@ -217,7 +211,7 @@ function useEditorSetup({
|
||||
editor, activeTab, rawLatestContentRef,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleRenameTabWithSync, handleViewCommitDiff,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
}
|
||||
}
|
||||
@@ -235,17 +229,19 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onContentChange, onSave, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
isDarkTheme, onFileCreated, onFileModified, onVaultChanged,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleRenameTabWithSync, handleViewCommitDiff,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange, onTitleSync,
|
||||
onRenameTab: props.onRenameTab, onLoadDiff: props.onLoadDiff,
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit, getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
@@ -260,7 +256,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNote={onCreateNote}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onRenameTab={handleRenameTabWithSync}
|
||||
onRenameTab={props.onRenameTab}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
onGoBack={onGoBack}
|
||||
@@ -300,6 +296,11 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
isDarkTheme={isDarkTheme}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onTitleChange={onTitleSync}
|
||||
onSetNoteIcon={onSetNoteIcon}
|
||||
onRemoveNoteIcon={onRemoveNoteIcon}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { TitleField } from './TitleField'
|
||||
import { NoteIcon } from './NoteIcon'
|
||||
import { TrashedNoteBanner } from './TrashedNoteBanner'
|
||||
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
|
||||
import { ConflictNoteBanner } from './ConflictNoteBanner'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -47,6 +51,16 @@ interface EditorContentProps {
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
/** Called when the user edits the dedicated title field. */
|
||||
onTitleChange?: (path: string, newTitle: string) => void
|
||||
/** Called when user sets or changes an emoji icon via the picker. */
|
||||
onSetNoteIcon?: (path: string, emoji: string) => void
|
||||
/** Called when user removes an emoji icon. */
|
||||
onRemoveNoteIcon?: (path: string) => void
|
||||
/** Whether the active note has a merge conflict. */
|
||||
isConflicted?: boolean
|
||||
/** Resolve conflict by keeping the local version. */
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -137,40 +151,32 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
)
|
||||
}
|
||||
|
||||
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed, rawLatestContentRef }: {
|
||||
activeTab: Tab | null; isLoadingNewTab: boolean; entries: VaultEntry[]
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
diffMode: boolean; diffContent: string | null; onToggleDiff: () => void
|
||||
rawMode: boolean; onRawContentChange?: (path: string, content: string) => void; onSave?: () => void
|
||||
onNavigateWikilink: (target: string) => void; onEditorChange?: () => void
|
||||
vaultPath?: string; isDarkTheme?: boolean; isTrashed: boolean
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
return (
|
||||
<>
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
|
||||
{showEditor && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditorContent({
|
||||
activeTab, isLoadingNewTab, entries, editor,
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
onDeleteNote, rawLatestContentRef, onTitleChange,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const isTrashed = activeTab?.entry.trashed ?? false
|
||||
const showTitleField = activeTab && !diffMode && !rawMode
|
||||
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
||||
// so the banner appears regardless of navigation context.
|
||||
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
|
||||
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
|
||||
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
|
||||
const showEditor = !diffMode && !rawMode
|
||||
const entryIcon = activeTab?.entry.icon ?? null
|
||||
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
|
||||
|
||||
const handleSetIcon = useCallback((emoji: string) => {
|
||||
if (activeTab) onSetNoteIcon?.(activeTab.entry.path, emoji)
|
||||
}, [activeTab, onSetNoteIcon])
|
||||
|
||||
const handleRemoveIcon = useCallback(() => {
|
||||
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
|
||||
}, [activeTab, onRemoveNoteIcon])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||
@@ -186,18 +192,38 @@ export function EditorContent({
|
||||
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
|
||||
/>
|
||||
)}
|
||||
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
|
||||
{activeTab && isArchived && breadcrumbProps.onUnarchiveNote && (
|
||||
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
|
||||
)}
|
||||
{showTitleField && (
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable={!isTrashed}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
||||
{activeTab && isConflicted && (
|
||||
<ConflictNoteBanner
|
||||
onKeepMine={() => onKeepMine?.(activeTab.entry.path)}
|
||||
onKeepTheirs={() => onKeepTheirs?.(activeTab.entry.path)}
|
||||
/>
|
||||
)}
|
||||
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} rawLatestContentRef={rawLatestContentRef} />
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
|
||||
{showEditor && activeTab && (
|
||||
<div className="editor-scroll-area">
|
||||
<div className="title-section">
|
||||
<NoteIcon
|
||||
icon={emojiIcon}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable={!isTrashed}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
||||
/>
|
||||
<div className="title-section__separator" />
|
||||
</div>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
143
src/components/EmojiPicker.tsx
Normal file
143
src/components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { EMOJI_GROUPS, EMOJIS_BY_GROUP, GROUP_ICONS, GROUP_SHORT_LABELS, searchEmojis } from '../utils/emoji'
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const groupRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler, true)
|
||||
return () => window.removeEventListener('keydown', handler, true)
|
||||
}, [onClose])
|
||||
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => document.addEventListener('mousedown', handler), 0)
|
||||
return () => { clearTimeout(timer); document.removeEventListener('mousedown', handler) }
|
||||
}, [onClose])
|
||||
|
||||
const handleSelect = useCallback((emoji: string) => {
|
||||
onSelect(emoji)
|
||||
onClose()
|
||||
}, [onSelect, onClose])
|
||||
|
||||
const scrollToGroup = useCallback((group: string) => {
|
||||
const el = groupRefs.current.get(group)
|
||||
if (el && scrollRef.current) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const searchResults = search.trim() ? searchEmojis(search) : null
|
||||
const isSearching = searchResults !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-50 w-[340px] rounded-lg border border-[var(--border-dialog)] bg-popover shadow-lg"
|
||||
style={{ left: 54, top: 0 }}
|
||||
data-testid="emoji-picker"
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Search emoji by name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
data-testid="emoji-picker-search"
|
||||
/>
|
||||
</div>
|
||||
{!isSearching && (
|
||||
<div className="flex gap-0.5 border-b border-border px-2 py-1.5 overflow-x-auto">
|
||||
{EMOJI_GROUPS.map(group => (
|
||||
<button
|
||||
key={group}
|
||||
className="shrink-0 rounded px-1.5 py-1 text-base transition-colors hover:bg-secondary"
|
||||
onClick={() => scrollToGroup(group)}
|
||||
title={GROUP_SHORT_LABELS[group]}
|
||||
>
|
||||
{GROUP_ICONS[group]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} className="max-h-[300px] overflow-y-auto p-2" data-testid="emoji-picker-grid">
|
||||
{isSearching ? (
|
||||
searchResults.length > 0 ? (
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{searchResults.map(entry => (
|
||||
<button
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
title={entry.name}
|
||||
data-testid="emoji-option"
|
||||
>
|
||||
{entry.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No emojis found
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
EMOJI_GROUPS.map(group => {
|
||||
const emojis = EMOJIS_BY_GROUP.get(group)
|
||||
if (!emojis?.length) return null
|
||||
return (
|
||||
<div
|
||||
key={group}
|
||||
ref={el => { if (el) groupRefs.current.set(group, el) }}
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-popover px-1 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{GROUP_SHORT_LABELS[group]}
|
||||
</div>
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{emojis.map(entry => (
|
||||
<button
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
title={entry.name}
|
||||
data-testid="emoji-option"
|
||||
>
|
||||
{entry.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -242,6 +242,36 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(screen.getByText('My Cool Project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows emoji icon before label when entry has an emoji', () => {
|
||||
const emojiEntry = makeEntry({
|
||||
path: '/vault/note/rocket.md', filename: 'rocket.md', title: 'Rocket Note', isA: 'Note', icon: '🚀',
|
||||
})
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[Rocket Note]]'] }}
|
||||
entries={[emojiEntry]}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('🚀')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rocket Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show emoji when entry has no icon', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
const link = screen.getByText('My Project')
|
||||
const container = link.closest('.group\\/link')
|
||||
expect(container?.textContent).not.toMatch(/^[\p{Emoji_Presentation}]/u)
|
||||
})
|
||||
|
||||
describe('relation editing', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
@@ -649,6 +679,29 @@ describe('BacklinksPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows emoji icon before backlink title when entry has an emoji', () => {
|
||||
const backlinks = [{
|
||||
entry: makeEntry({ title: 'Starred Note', icon: '⭐' }),
|
||||
context: null,
|
||||
}]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('⭐')).toBeInTheDocument()
|
||||
expect(screen.getByText('Starred Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show emoji when backlink entry has no icon', () => {
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Plain Note' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Plain Note')).toBeInTheDocument()
|
||||
const btn = screen.getByText('Plain Note').closest('button')
|
||||
const spans = btn?.querySelectorAll('span.shrink-0')
|
||||
// No emoji span should exist (only possible shrink-0 spans are trash icon, not emoji)
|
||||
const emojiSpans = Array.from(spans ?? []).filter(s => /[\p{Emoji_Presentation}]/u.test(s.textContent ?? ''))
|
||||
expect(emojiSpans).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReferencedByPanel', () => {
|
||||
|
||||
78
src/components/NoteIcon.test.tsx
Normal file
78
src/components/NoteIcon.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { NoteIcon } from './NoteIcon'
|
||||
|
||||
describe('NoteIcon', () => {
|
||||
it('shows add button when no icon is set', () => {
|
||||
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('note-icon-display')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays the emoji when icon is set', () => {
|
||||
render(<NoteIcon icon="🎯" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
expect(screen.getByTestId('note-icon-display')).toHaveTextContent('🎯')
|
||||
expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not display Phosphor icon names as emoji', () => {
|
||||
render(<NoteIcon icon="rocket" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
// Phosphor icon name is not an emoji → shows add button
|
||||
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens emoji picker when add button is clicked', () => {
|
||||
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
fireEvent.click(screen.getByTestId('note-icon-add'))
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows change/remove menu when existing icon is clicked', () => {
|
||||
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
fireEvent.click(screen.getByTestId('note-icon-display'))
|
||||
expect(screen.getByTestId('note-icon-menu')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('note-icon-change')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('note-icon-remove')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRemoveIcon when Remove is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={onRemove} />)
|
||||
fireEvent.click(screen.getByTestId('note-icon-display'))
|
||||
fireEvent.click(screen.getByTestId('note-icon-remove'))
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens picker when Change is clicked from menu', () => {
|
||||
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
fireEvent.click(screen.getByTestId('note-icon-display'))
|
||||
fireEvent.click(screen.getByTestId('note-icon-change'))
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSetIcon when emoji is selected from picker', () => {
|
||||
const onSet = vi.fn()
|
||||
render(<NoteIcon icon={null} onSetIcon={onSet} onRemoveIcon={() => {}} />)
|
||||
fireEvent.click(screen.getByTestId('note-icon-add'))
|
||||
const emojiButtons = screen.getAllByTestId('emoji-option')
|
||||
fireEvent.click(emojiButtons[0])
|
||||
expect(onSet).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides add button when not editable', () => {
|
||||
render(<NoteIcon icon={null} editable={false} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables icon click when not editable', () => {
|
||||
render(<NoteIcon icon="🎯" editable={false} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
const display = screen.getByTestId('note-icon-display')
|
||||
expect(display).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens picker on laputa:open-icon-picker event', () => {
|
||||
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
|
||||
act(() => { window.dispatchEvent(new CustomEvent('laputa:open-icon-picker')) })
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
102
src/components/NoteIcon.tsx
Normal file
102
src/components/NoteIcon.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
|
||||
interface NoteIconProps {
|
||||
icon: string | null
|
||||
editable?: boolean
|
||||
onSetIcon: (emoji: string) => void
|
||||
onRemoveIcon: () => void
|
||||
}
|
||||
|
||||
export function NoteIcon({ icon, editable = true, onSetIcon, onRemoveIcon }: NoteIconProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [showRemove, setShowRemove] = useState(false)
|
||||
|
||||
const hasIcon = icon && isEmoji(icon)
|
||||
|
||||
// Listen for command palette "Set Note Icon" event
|
||||
useEffect(() => {
|
||||
if (!editable) return
|
||||
const handler = () => setPickerOpen(true)
|
||||
window.addEventListener('laputa:open-icon-picker', handler)
|
||||
return () => window.removeEventListener('laputa:open-icon-picker', handler)
|
||||
}, [editable])
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
if (!editable) return
|
||||
if (hasIcon) {
|
||||
setShowRemove(prev => !prev)
|
||||
} else {
|
||||
setPickerOpen(true)
|
||||
}
|
||||
}, [editable, hasIcon])
|
||||
|
||||
const handleSelect = useCallback((emoji: string) => {
|
||||
onSetIcon(emoji)
|
||||
setPickerOpen(false)
|
||||
setShowRemove(false)
|
||||
}, [onSetIcon])
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemoveIcon()
|
||||
setShowRemove(false)
|
||||
}, [onRemoveIcon])
|
||||
|
||||
const handleChangePicker = useCallback(() => {
|
||||
setShowRemove(false)
|
||||
setPickerOpen(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="note-icon-area" data-testid="note-icon-area" style={{ position: 'relative' }}>
|
||||
{hasIcon ? (
|
||||
<button
|
||||
className="note-icon-button note-icon-button--active"
|
||||
onClick={openPicker}
|
||||
data-testid="note-icon-display"
|
||||
title={editable ? 'Change or remove icon' : undefined}
|
||||
disabled={!editable}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
) : (
|
||||
editable && (
|
||||
<button
|
||||
className="note-icon-button note-icon-button--add"
|
||||
onClick={openPicker}
|
||||
data-testid="note-icon-add"
|
||||
title="Add icon"
|
||||
>
|
||||
<span className="note-icon-button__plus">+</span>
|
||||
<span className="note-icon-button__label">Add icon</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{showRemove && hasIcon && (
|
||||
<div className="note-icon-menu" data-testid="note-icon-menu">
|
||||
<button
|
||||
className="note-icon-menu__item"
|
||||
onClick={handleChangePicker}
|
||||
data-testid="note-icon-change"
|
||||
>
|
||||
Change icon
|
||||
</button>
|
||||
<button
|
||||
className="note-icon-menu__item note-icon-menu__item--danger"
|
||||
onClick={handleRemove}
|
||||
data-testid="note-icon-remove"
|
||||
>
|
||||
Remove icon
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{pickerOpen && (
|
||||
<EmojiPicker
|
||||
onSelect={handleSelect}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
@@ -124,12 +125,13 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
<StateBadge archived={entry.archived} trashed={entry.trashed} />
|
||||
</div>
|
||||
</div>
|
||||
{entry.snippet && (
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,15 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import { NoteListHeader } from './note-list/NoteListHeader'
|
||||
import { FilterPills } from './note-list/FilterPills'
|
||||
import { InboxFilterPills } from './note-list/InboxFilterPills'
|
||||
import { EntityView, ListView } from './note-list/NoteListViews'
|
||||
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
|
||||
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
|
||||
@@ -18,6 +22,10 @@ interface NoteListProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
selectedNote: VaultEntry | null
|
||||
noteListFilter: NoteListFilter
|
||||
onNoteListFilterChange: (filter: NoteListFilter) => void
|
||||
inboxPeriod?: InboxPeriod
|
||||
onInboxPeriodChange?: (period: InboxPeriod) => void
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
modifiedFilesError?: string | null
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
@@ -32,16 +40,32 @@ interface NoteListProps {
|
||||
onEmptyTrash?: () => void
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const subFilter = isSectionGroup ? noteListFilter : undefined
|
||||
|
||||
const filterCounts = useMemo(
|
||||
() => isSectionGroup ? countByFilter(entries, selection.type) : { open: 0, archived: 0, trashed: 0 },
|
||||
[entries, isSectionGroup, selection],
|
||||
)
|
||||
|
||||
const inboxCounts = useMemo(
|
||||
() => isInboxView ? countInboxByPeriod(entries) : { week: 0, month: 0, quarter: 0, all: 0 },
|
||||
[entries, isInboxView],
|
||||
)
|
||||
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, 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, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined })
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const deletedCount = useMemo(
|
||||
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
|
||||
@@ -54,14 +78,15 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
|
||||
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, multiSelect })
|
||||
}, [onReplaceActiveTab, onSelectNote, multiSelect])
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
|
||||
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect])
|
||||
|
||||
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
|
||||
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
|
||||
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
|
||||
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
|
||||
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : handleBulkArchive
|
||||
const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
|
||||
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : isArchivedView ? handleBulkUnarchive : handleBulkArchive
|
||||
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
|
||||
|
||||
@@ -75,18 +100,20 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
{isSectionGroup && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} />}
|
||||
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{entitySelection ? (
|
||||
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
) : (
|
||||
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
<ListView isTrashView={isTrashView} isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
)}
|
||||
</div>
|
||||
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onClear={multiSelect.clear} />
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
|
||||
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { formatSearchSubtitle } from '../utils/noteListHelpers'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
|
||||
interface SearchPanelProps {
|
||||
open: boolean
|
||||
@@ -210,7 +211,10 @@ function SearchContent({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{result.title}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
{entry?.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{result.title}
|
||||
</span>
|
||||
{noteType && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GearSix, CookingPot, FileText } from '@phosphor-icons/react'
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
path: '', filename: '', title: '', isA: null, aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
status: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
|
||||
@@ -359,13 +359,13 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Trading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with topic kind when clicking a topic', () => {
|
||||
it('calls onSelect with entity kind when clicking a topic', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Topics'))
|
||||
fireEvent.click(screen.getByText('Software Development'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'topic',
|
||||
kind: 'entity',
|
||||
entry: mockEntries[4],
|
||||
})
|
||||
})
|
||||
@@ -418,6 +418,36 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'changes' })
|
||||
})
|
||||
|
||||
describe('Changes and Pulse in secondary bottom area', () => {
|
||||
it('renders Changes outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const changesEl = screen.getByText('Changes')
|
||||
// Changes should be inside the secondary bottom area, not the top nav
|
||||
const secondaryArea = changesEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders Pulse outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} isGitVault />)
|
||||
const pulseEl = screen.getByText('Pulse')
|
||||
const secondaryArea = pulseEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not render Changes or Pulse inside the top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.textContent).not.toContain('Changes')
|
||||
expect(topNav.textContent).not.toContain('Pulse')
|
||||
})
|
||||
|
||||
it('shows Changes badge count in secondary area', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={7} isGitVault />)
|
||||
const secondaryArea = screen.getByTestId('sidebar-secondary')
|
||||
expect(secondaryArea.textContent).toContain('7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic custom type sections', () => {
|
||||
const entriesWithCustomTypes: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
@@ -914,6 +944,63 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Note type in sidebar', () => {
|
||||
const noteEntries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
{
|
||||
path: '/vault/note.md', filename: 'note.md', title: 'Note', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/explicit-note.md', filename: 'explicit-note.md', title: 'Explicit Note',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 300, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/untyped-note.md', filename: 'untyped-note.md', title: 'Untyped Note',
|
||||
isA: null, aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
it('shows Notes section when Note entries exist', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes both explicit and untyped notes under Notes section', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Notes'))
|
||||
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Untyped Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Notes section for untyped entries even without explicit Note entries', () => {
|
||||
const untypedOnly: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/plain.md', filename: 'plain.md', title: 'Plain Note',
|
||||
isA: null, aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 100, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={untypedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders exactly one section for a hyphenated custom type like Monday Ideas', () => {
|
||||
const entriesWithMondayIdeas: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
@@ -951,4 +1038,24 @@ describe('Sidebar', () => {
|
||||
const mondaySections = screen.getAllByText(/Monday Ideas/i)
|
||||
expect(mondaySections).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders Inbox as the first item in the top nav', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={5} />)
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
const items = topNav.children
|
||||
expect(items[0].textContent).toContain('Inbox')
|
||||
expect(items[1].textContent).toContain('All Notes')
|
||||
})
|
||||
|
||||
it('displays inbox count badge', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={12} />)
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with inbox filter when clicking Inbox', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} inboxCount={3} />)
|
||||
fireEvent.click(screen.getByText('Inbox'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, Tray,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ interface SidebarProps {
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
modifiedCount?: number
|
||||
inboxCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
isGitVault?: boolean
|
||||
@@ -115,7 +116,9 @@ function SortableSection({ group, sectionProps }: {
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
|
||||
const items = sectionProps.entries.filter((e) =>
|
||||
!e.archived && !e.trashed && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type),
|
||||
)
|
||||
const isCollapsed = sectionProps.collapsed[group.type] ?? true
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
@@ -220,7 +223,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
|
||||
modifiedCount = 0, inboxCount = 0, onCommitPush, onCollapse, isGitVault = false,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -302,14 +305,11 @@ export const Sidebar = memo(function Sidebar({
|
||||
<SidebarTitleBar onCollapse={onCollapse} />
|
||||
<nav className="flex-1 overflow-y-auto">
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
|
||||
)}
|
||||
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} />
|
||||
</div>
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
@@ -333,6 +333,13 @@ export const Sidebar = memo(function Sidebar({
|
||||
</DndContext>
|
||||
</nav>
|
||||
|
||||
{/* Secondary area: Changes + Pulse */}
|
||||
<div className="shrink-0 border-t border-border" data-testid="sidebar-secondary" style={{ padding: '4px 6px' }}>
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} compact />
|
||||
)}
|
||||
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} compact />
|
||||
</div>
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
@@ -18,15 +19,14 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
|
||||
switch (check.kind) {
|
||||
case 'filter': return (current as typeof check).filter === check.filter
|
||||
case 'sectionGroup': return (current as typeof check).type === check.type
|
||||
case 'entity':
|
||||
case 'topic': return (current as typeof check).entry.path === check.entry.path
|
||||
case 'entity': return (current as typeof check).entry.path === check.entry.path
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- NavItem ---
|
||||
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip }: {
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
icon: ComponentType<IconProps>
|
||||
label: string
|
||||
count?: number
|
||||
@@ -37,25 +37,30 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const iconSize = compact ? 14 : 16
|
||||
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
|
||||
const padding = compact ? '4px 16px' : '6px 16px'
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
|
||||
<Icon size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">{label}</span>
|
||||
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
|
||||
<Icon size={iconSize} />
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
|
||||
style={{ padding: '6px 16px', borderRadius: 4 }}
|
||||
style={{ padding, borderRadius: 4 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">{label}</span>
|
||||
<Icon size={iconSize} />
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
|
||||
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
@@ -83,8 +88,8 @@ export interface SectionContentProps {
|
||||
onRenameCancel?: () => void
|
||||
}
|
||||
|
||||
function childSelection(type: string, entry: VaultEntry): SidebarSelection {
|
||||
return type === 'Topic' ? { kind: 'topic', entry } : { kind: 'entity', entry }
|
||||
function childSelection(entry: VaultEntry): SidebarSelection {
|
||||
return { kind: 'entity', entry }
|
||||
}
|
||||
|
||||
function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined {
|
||||
@@ -123,7 +128,7 @@ export function SectionContent({
|
||||
/>
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<SectionChildList
|
||||
items={items} type={type} selection={selection}
|
||||
items={items} selection={selection}
|
||||
sectionColor={sectionColor} sectionLightColor={sectionLightColor}
|
||||
onSelect={onSelect} onSelectNote={onSelectNote}
|
||||
/>
|
||||
@@ -132,19 +137,19 @@ export function SectionContent({
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildList({ items, type, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: {
|
||||
items: VaultEntry[]; type: string; selection: SidebarSelection
|
||||
function SectionChildList({ items, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: {
|
||||
items: VaultEntry[]; selection: SidebarSelection
|
||||
sectionColor: string; sectionLightColor: string
|
||||
onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.map((entry) => {
|
||||
const sel = childSelection(type, entry)
|
||||
const sel = childSelection(entry)
|
||||
const active = isSelectionActive(selection, sel)
|
||||
return (
|
||||
<SectionChildItem
|
||||
key={entry.path} title={entry.title} isActive={active}
|
||||
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
|
||||
sectionColor={active ? sectionColor : undefined}
|
||||
sectionLightColor={active ? sectionLightColor : undefined}
|
||||
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
|
||||
@@ -233,8 +238,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; isActive: boolean
|
||||
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; icon?: string | null; isActive: boolean
|
||||
sectionColor?: string; sectionLightColor?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
@@ -244,7 +249,7 @@ function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, on
|
||||
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title}
|
||||
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,6 +428,40 @@ describe('StatusBar', () => {
|
||||
expect(onReindexVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Pull required label when syncStatus is pull_required', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" />
|
||||
)
|
||||
expect(screen.getByText('Pull required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onPullAndPush when clicking Pull required badge', () => {
|
||||
const onPullAndPush = vi.fn()
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" onPullAndPush={onPullAndPush} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onPullAndPush).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows git status popup when clicking idle sync badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
syncStatus="idle"
|
||||
remoteStatus={{ branch: 'main', ahead: 2, behind: 1, hasRemote: true }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(screen.getByTestId('git-status-popup')).toBeInTheDocument()
|
||||
expect(screen.getByText('main')).toBeInTheDocument()
|
||||
expect(screen.getByText(/2 ahead/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides indexed time badge when no lastIndexedTime', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu } from 'lucide-react'
|
||||
import type { LastCommitInfo, SyncStatus } from '../types'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { IndexingProgress } from '../hooks/useIndexing'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
@@ -27,7 +27,9 @@ interface StatusBarProps {
|
||||
lastSyncTime?: number | null
|
||||
conflictCount?: number
|
||||
lastCommitInfo?: LastCommitInfo | null
|
||||
remoteStatus?: GitRemoteStatus | null
|
||||
onTriggerSync?: () => void
|
||||
onPullAndPush?: () => void
|
||||
onOpenConflictResolver?: () => void
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
@@ -159,10 +161,10 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn
|
||||
const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
|
||||
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
const SEP_STYLE = { color: 'var(--border)' } as const
|
||||
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
|
||||
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle, pull_required: ArrowDown }
|
||||
|
||||
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
|
||||
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
|
||||
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed', pull_required: 'Pull required' }
|
||||
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)', pull_required: 'var(--accent-orange)' }
|
||||
|
||||
function formatElapsedSync(lastSyncTime: number | null): string {
|
||||
if (!lastSyncTime) return 'Not synced'
|
||||
@@ -201,21 +203,114 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) {
|
||||
function syncBadgeTitle(status: SyncStatus): string {
|
||||
if (status === 'conflict') return 'Click to resolve conflicts'
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
if (status === 'pull_required') return 'Click to pull from remote and push'
|
||||
return 'Click to sync now'
|
||||
}
|
||||
|
||||
function SyncBadge({ status, lastSyncTime, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; remoteStatus?: GitRemoteStatus | null; onTriggerSync?: () => void; onPullAndPush?: () => void; onOpenConflictResolver?: () => void }) {
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
|
||||
const isSyncing = status === 'syncing'
|
||||
const isConflict = status === 'conflict'
|
||||
const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync
|
||||
const isPullRequired = status === 'pull_required'
|
||||
|
||||
const handleClick = () => {
|
||||
if (isConflict) { onOpenConflictResolver?.(); return }
|
||||
if (isPullRequired) { onPullAndPush?.(); return }
|
||||
setShowPopup(v => !v)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPopup) return
|
||||
const handleOutside = (e: MouseEvent) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target as Node)) setShowPopup(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutside)
|
||||
return () => document.removeEventListener('mousedown', handleOutside)
|
||||
}, [showPopup])
|
||||
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
onClick={handleClick}
|
||||
style={{ ...ICON_STYLE, cursor: handleClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={isConflict ? 'Click to resolve conflicts' : isSyncing ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
<div ref={popupRef} style={{ position: 'relative' }}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={handleClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={syncBadgeTitle(status)}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
{showPopup && (
|
||||
<GitStatusPopup
|
||||
status={status}
|
||||
remoteStatus={remoteStatus ?? null}
|
||||
onPull={onTriggerSync}
|
||||
onClose={() => setShowPopup(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GitStatusPopup({ status, remoteStatus, onPull, onClose }: { status: SyncStatus; remoteStatus: GitRemoteStatus | null; onPull?: () => void; onClose: () => void }) {
|
||||
const branch = remoteStatus?.branch || '—'
|
||||
const ahead = remoteStatus?.ahead ?? 0
|
||||
const behind = remoteStatus?.behind ?? 0
|
||||
const hasRemote = remoteStatus?.hasRemote ?? false
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="git-status-popup"
|
||||
style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
|
||||
background: 'var(--sidebar)', border: '1px solid var(--border)',
|
||||
borderRadius: 6, padding: 8, minWidth: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
zIndex: 1000, fontSize: 12, color: 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<GitBranch size={13} style={{ color: 'var(--muted-foreground)' }} />
|
||||
<span style={{ fontWeight: 500 }}>{branch}</span>
|
||||
</div>
|
||||
|
||||
{hasRemote && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 6, color: 'var(--muted-foreground)' }}>
|
||||
{ahead > 0 && <span title={`${ahead} commit${ahead > 1 ? 's' : ''} ahead of remote`}>↑ {ahead} ahead</span>}
|
||||
{behind > 0 && <span title={`${behind} commit${behind > 1 ? 's' : ''} behind remote`} style={{ color: 'var(--accent-orange)' }}>↓ {behind} behind</span>}
|
||||
{ahead === 0 && behind === 0 && <span>In sync with remote</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasRemote && (
|
||||
<div style={{ color: 'var(--muted-foreground)', marginBottom: 6 }}>No remote configured</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, color: 'var(--muted-foreground)' }}>
|
||||
Status: {status === 'idle' ? 'Synced' : status === 'pull_required' ? 'Pull required' : status === 'conflict' ? 'Conflicts' : status === 'error' ? 'Error' : status === 'syncing' ? 'Syncing…' : status}
|
||||
</div>
|
||||
|
||||
{hasRemote && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6, borderTop: '1px solid var(--border)', paddingTop: 6 }}>
|
||||
<button
|
||||
onClick={() => { onPull?.(); onClose() }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px',
|
||||
background: 'transparent', border: '1px solid var(--border)', borderRadius: 4,
|
||||
fontSize: 11, color: 'var(--foreground)', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="git-status-pull-btn"
|
||||
>
|
||||
<ArrowDown size={11} />Pull
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -356,7 +451,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -378,7 +473,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import { Plus, Columns, ArrowsOutSimple, ArrowLeft, ArrowRight } from '@phosphor-icons/react'
|
||||
import { computeTabMaxWidth } from '@/utils/tabLayout'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -181,7 +182,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
|
||||
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Unsaved — Cmd+S to save' },
|
||||
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Auto-saving…', pulse: true },
|
||||
pendingSave: { color: 'var(--accent-green)', testId: 'tab-pending-save-indicator', title: 'Saving to disk…', pulse: true },
|
||||
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
|
||||
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
|
||||
@@ -241,7 +242,8 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
{isEditing ? (
|
||||
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
|
||||
) : (
|
||||
<span className="truncate" style={noteStatus === 'unsaved' ? { fontStyle: 'italic' } : undefined} onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
{tab.entry.icon && isEmoji(tab.entry.icon) && <span className="mr-1">{tab.entry.icon}</span>}
|
||||
{tab.entry.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,29 @@ describe('TitleField', () => {
|
||||
expect(input).toHaveValue('Original')
|
||||
})
|
||||
|
||||
it('shows new title optimistically after commit (before prop updates)', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: 'New Title' } })
|
||||
fireEvent.blur(input)
|
||||
// After commit, should show new title even though prop is still "Old Title"
|
||||
expect(input).toHaveValue('New Title')
|
||||
// After prop updates to match, should still show new title
|
||||
rerender(<TitleField title="New Title" filename="new-title.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('New Title')
|
||||
})
|
||||
|
||||
it('resets optimistic title when prop changes from external source', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Title A" filename="title-a.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
// Simulate external title change (e.g., tab switch)
|
||||
rerender(<TitleField title="Title B" filename="title-b.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('Title B')
|
||||
})
|
||||
|
||||
it('responds to laputa:focus-editor event with selectTitle', () => {
|
||||
render(<TitleField title="Focus Me" filename="focus-me.md" onTitleChange={() => {}} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
|
||||
@@ -9,20 +9,50 @@ interface TitleFieldProps {
|
||||
onTitleChange: (newTitle: string) => void
|
||||
}
|
||||
|
||||
/** Manages local edit + optimistic title state for TitleField. */
|
||||
function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
const [localValue, setLocalValue] = useState<string | null>(null)
|
||||
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
|
||||
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
|
||||
const isFocusedRef = useRef(false)
|
||||
|
||||
// Clear optimistic once the prop changes (rename completed or tab switched)
|
||||
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
|
||||
const value = localValue ?? optimisticValue ?? title
|
||||
const isEditing = localValue !== null || optimisticValue !== null
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
isFocusedRef.current = true
|
||||
setLocalValue(title)
|
||||
}, [title])
|
||||
|
||||
const commitTitle = useCallback(() => {
|
||||
isFocusedRef.current = false
|
||||
const trimmed = (localValue ?? '').trim()
|
||||
if (trimmed && trimmed !== title) {
|
||||
setLocalValue(null)
|
||||
setOptimistic([trimmed, title])
|
||||
onTitleChange(trimmed)
|
||||
} else {
|
||||
setLocalValue(null)
|
||||
}
|
||||
}, [localValue, title, onTitleChange])
|
||||
|
||||
const revert = useCallback(() => setLocalValue(null), [])
|
||||
const setEdit = useCallback((v: string) => setLocalValue(v), [])
|
||||
|
||||
return { value, isEditing, handleFocus, commitTitle, revert, setEdit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated title input field above the editor.
|
||||
* Displays the title as an editable field and shows the resulting filename below.
|
||||
* Replaces the H1 block as the primary title editing surface.
|
||||
*/
|
||||
export function TitleField({ title, filename, editable = true, onTitleChange }: TitleFieldProps) {
|
||||
const [localValue, setLocalValue] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const isFocusedRef = useRef(false)
|
||||
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
||||
useOptimisticTitle(title, onTitleChange)
|
||||
|
||||
// The displayed value: use local edit value while focused, otherwise prop title
|
||||
const value = localValue ?? title
|
||||
|
||||
// Listen for laputa:focus-editor with selectTitle to focus this field
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
@@ -35,34 +65,20 @@ export function TitleField({ title, filename, editable = true, onTitleChange }:
|
||||
return () => window.removeEventListener('laputa:focus-editor', handler)
|
||||
}, [])
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
isFocusedRef.current = true
|
||||
setLocalValue(title)
|
||||
}, [title])
|
||||
|
||||
const commitTitle = useCallback(() => {
|
||||
isFocusedRef.current = false
|
||||
const trimmed = (localValue ?? '').trim()
|
||||
if (trimmed && trimmed !== title) {
|
||||
onTitleChange(trimmed)
|
||||
}
|
||||
setLocalValue(null) // reset to prop-driven
|
||||
}, [localValue, title, onTitleChange])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setLocalValue(null) // revert to prop
|
||||
revert()
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}, [])
|
||||
}, [revert])
|
||||
|
||||
const expectedSlug = slugify(value.trim() || title)
|
||||
const currentStem = filename.replace(/\.md$/, '')
|
||||
const showFilename = localValue !== null || currentStem !== expectedSlug
|
||||
const showFilename = isEditing || currentStem !== expectedSlug
|
||||
|
||||
return (
|
||||
<div className="title-field" data-testid="title-field">
|
||||
@@ -70,7 +86,7 @@ export function TitleField({ title, filename, editable = true, onTitleChange }:
|
||||
ref={inputRef}
|
||||
className="title-field__input"
|
||||
value={value}
|
||||
onChange={e => setLocalValue(e.target.value)}
|
||||
onChange={e => setEdit(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -22,17 +22,19 @@ function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,17 +53,28 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
? [...availableTypes, isA].sort((a, b) => a.localeCompare(b))
|
||||
: availableTypes
|
||||
|
||||
const typeColor = isA ? getTypeColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
|
||||
const typeLightColor = isA ? getTypeLightColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<div className="min-w-0">
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={`h-auto max-w-full gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
|
||||
style={{
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
<SelectItem value={TYPE_NONE}>None</SelectItem>
|
||||
<SelectSeparator />
|
||||
@@ -71,7 +84,8 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createReactInlineContentSpec } from '@blocknote/react'
|
||||
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
|
||||
// Module-level cache so the WikiLink renderer (defined outside React) can access entries
|
||||
export const _wikilinkEntriesRef: { current: VaultEntry[] } = { current: [] }
|
||||
@@ -12,15 +13,22 @@ function resolveWikilinkColor(target: string) {
|
||||
return resolveColor(_wikilinkEntriesRef.current, target)
|
||||
}
|
||||
|
||||
/** Resolve the display text for a wikilink target.
|
||||
/** Resolve the display text and optional emoji for a wikilink target.
|
||||
* Priority: pipe display text → entry title → humanised path stem */
|
||||
function resolveDisplayText(target: string): string {
|
||||
function resolveDisplayInfo(target: string): { text: string; emoji: string | null } {
|
||||
const pipeIdx = target.indexOf('|')
|
||||
if (pipeIdx !== -1) return target.slice(pipeIdx + 1)
|
||||
if (pipeIdx !== -1) {
|
||||
const entry = resolveEntry(_wikilinkEntriesRef.current, target.slice(0, pipeIdx))
|
||||
const emoji = entry?.icon && isEmoji(entry.icon) ? entry.icon : null
|
||||
return { text: target.slice(pipeIdx + 1), emoji }
|
||||
}
|
||||
const entry = resolveEntry(_wikilinkEntriesRef.current, target)
|
||||
if (entry) return entry.title
|
||||
if (entry) {
|
||||
const emoji = entry.icon && isEmoji(entry.icon) ? entry.icon : null
|
||||
return { text: entry.title, emoji }
|
||||
}
|
||||
const last = target.split('/').pop() ?? target
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
return { text: last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), emoji: null }
|
||||
}
|
||||
|
||||
export const WikiLink = createReactInlineContentSpec(
|
||||
@@ -35,14 +43,15 @@ export const WikiLink = createReactInlineContentSpec(
|
||||
render: (props) => {
|
||||
const target = props.inlineContent.props.target
|
||||
const { color, isBroken } = resolveWikilinkColor(target)
|
||||
const displayText = resolveDisplayText(target)
|
||||
const { text, emoji } = resolveDisplayInfo(target)
|
||||
return (
|
||||
<span
|
||||
className={`wikilink${isBroken ? ' wikilink--broken' : ''}`}
|
||||
data-target={target}
|
||||
style={{ color }}
|
||||
>
|
||||
{displayText}
|
||||
{emoji && <span className="wikilink-emoji">{emoji}{' '}</span>}
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { CaretRight, Trash } from '@phosphor-icons/react'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { isEmoji } from '../../utils/emoji'
|
||||
import { entryStatusTitle } from './shared'
|
||||
import { StatusSuffix } from './LinkButton'
|
||||
|
||||
@@ -29,6 +30,7 @@ function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
|
||||
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
|
||||
>
|
||||
{entry.trashed && <Trash size={12} className="shrink-0" />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="shrink-0">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
|
||||
</span>
|
||||
|
||||
@@ -7,8 +7,9 @@ export function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; i
|
||||
return null
|
||||
}
|
||||
|
||||
export function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: {
|
||||
export function LinkButton({ label, emoji, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: {
|
||||
label: string
|
||||
emoji?: string | null
|
||||
typeColor: string
|
||||
bgColor?: string
|
||||
isArchived: boolean
|
||||
@@ -33,6 +34,7 @@ export function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, o
|
||||
>
|
||||
<span className="flex items-center gap-1 flex-1 truncate">
|
||||
{isTrashed && <Trash size={12} className="shrink-0" />}
|
||||
{emoji && <span className="shrink-0">{emoji}</span>}
|
||||
{label}
|
||||
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { VaultEntry } from '../../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { findEntryByTarget } from '../../utils/wikilinkColors'
|
||||
import { isEmoji } from '../../utils/emoji'
|
||||
|
||||
export function isWikilink(value: string): boolean {
|
||||
return /^\[\[.*\]\]$/.test(value)
|
||||
@@ -30,8 +31,10 @@ export function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap
|
||||
const resolved = resolveRef(ref, entries)
|
||||
const refType = resolved?.isA ?? null
|
||||
const te = typeEntryMap[refType ?? '']
|
||||
const icon = resolved?.icon
|
||||
return {
|
||||
label: wikilinkDisplay(ref),
|
||||
emoji: icon && isEmoji(icon) ? icon : null,
|
||||
typeColor: getTypeColor(refType, te?.color),
|
||||
bgColor: getTypeLightColor(refType, te?.color),
|
||||
isArchived: resolved?.archived ?? false,
|
||||
|
||||
43
src/components/note-list/FilterPills.tsx
Normal file
43
src/components/note-list/FilterPills.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { memo } from 'react'
|
||||
import type { NoteListFilter } from '../../utils/noteListHelpers'
|
||||
|
||||
interface FilterPillsProps {
|
||||
active: NoteListFilter
|
||||
counts: Record<NoteListFilter, number>
|
||||
onChange: (filter: NoteListFilter) => void
|
||||
}
|
||||
|
||||
const PILLS: { value: NoteListFilter; label: string }[] = [
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
{ value: 'trashed', label: 'Trashed' },
|
||||
]
|
||||
|
||||
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
|
||||
return (
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => onChange(value)}
|
||||
data-testid={`filter-pill-${value}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
|
||||
{counts[value]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterPills = memo(FilterPillsInner)
|
||||
44
src/components/note-list/InboxFilterPills.tsx
Normal file
44
src/components/note-list/InboxFilterPills.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { memo } from 'react'
|
||||
import type { InboxPeriod } from '../../types'
|
||||
|
||||
interface InboxFilterPillsProps {
|
||||
active: InboxPeriod
|
||||
counts: Record<InboxPeriod, number>
|
||||
onChange: (period: InboxPeriod) => void
|
||||
}
|
||||
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'week', label: 'This week' },
|
||||
{ value: 'month', label: 'This month' },
|
||||
{ value: 'quarter', label: 'This quarter' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
]
|
||||
|
||||
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
|
||||
return (
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="inbox-filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => onChange(value)}
|
||||
data-testid={`inbox-pill-${value}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
|
||||
{counts[value]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InboxFilterPills = memo(InboxFilterPillsInner)
|
||||
@@ -11,10 +11,12 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
|
||||
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
}
|
||||
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, query: string): string {
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, isInboxView: boolean, query: string): string {
|
||||
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
|
||||
if (isChangesView) return 'No pending changes'
|
||||
if (isTrashView) return 'Trash is empty'
|
||||
if (isArchivedView) return 'No archived notes'
|
||||
if (isInboxView) return query ? 'No matching notes' : 'All notes are organized'
|
||||
return query ? 'No matching notes' : 'No notes found'
|
||||
}
|
||||
|
||||
@@ -38,13 +40,13 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
|
||||
)
|
||||
}
|
||||
|
||||
export function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
|
||||
export function ListView({ isTrashView, isArchivedView, isChangesView, isInboxView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null; expiredTrashCount: number
|
||||
deletedCount?: number; searched: VaultEntry[]; query: string
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
|
||||
}) {
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query)
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, !!isInboxView, query)
|
||||
const hasHeader = isTrashView && expiredTrashCount > 0
|
||||
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { VaultEntry } from '../../types'
|
||||
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { relativeDate, getDisplayDate } from '../../utils/noteListHelpers'
|
||||
import { isEmoji } from '../../utils/emoji'
|
||||
|
||||
export function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
|
||||
entry: VaultEntry
|
||||
@@ -17,7 +18,10 @@ export function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
|
||||
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={(e: React.MouseEvent) => onClickNote(entry, e)}>
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
|
||||
<div className="pr-6 text-[14px] font-bold" style={{ color }}>
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
|
||||
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig,
|
||||
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
|
||||
getSortComparator, extractSortableProperties,
|
||||
buildRelationshipGroups, filterEntries,
|
||||
buildRelationshipGroups, filterEntries, filterInboxEntries,
|
||||
loadSortPreferences, saveSortPreferences,
|
||||
parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage,
|
||||
} from '../../utils/noteListHelpers'
|
||||
import type { InboxPeriod } from '../../types'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
@@ -19,14 +20,16 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
|
||||
|
||||
// --- useFilteredEntries ---
|
||||
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[]) {
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
return useMemo(() => {
|
||||
if (isEntityView) return []
|
||||
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
|
||||
return filterEntries(entries, selection)
|
||||
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
|
||||
if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month')
|
||||
return filterEntries(entries, selection, subFilter)
|
||||
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod])
|
||||
}
|
||||
|
||||
// --- useNoteListData ---
|
||||
@@ -35,13 +38,16 @@ interface NoteListDataParams {
|
||||
entries: VaultEntry[]; selection: SidebarSelection
|
||||
query: string; listSort: SortOption; listDirection: SortDirection
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
inboxPeriod?: InboxPeriod
|
||||
}
|
||||
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
||||
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
|
||||
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
|
||||
|
||||
const searched = useMemo(() => {
|
||||
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
|
||||
@@ -50,7 +56,10 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
|
||||
|
||||
const searchedGroups = useMemo(() => {
|
||||
if (!isEntityView) return []
|
||||
const groups = buildRelationshipGroups(selection.entry, entries)
|
||||
// Look up the fresh entry from the entries array to pick up relationship
|
||||
// updates that happened after the selection was captured.
|
||||
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
|
||||
const groups = buildRelationshipGroups(freshEntry, entries)
|
||||
return filterGroupsByQuery(groups, query)
|
||||
}, [isEntityView, selection, entries, query])
|
||||
|
||||
@@ -59,7 +68,7 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
|
||||
[isTrashView, searched],
|
||||
)
|
||||
|
||||
return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount }
|
||||
return { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount }
|
||||
}
|
||||
|
||||
// --- useNoteListSearch ---
|
||||
@@ -122,11 +131,13 @@ export interface UseNoteListSortParams {
|
||||
selection: SidebarSelection
|
||||
modifiedPathSet: Set<string>
|
||||
modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
inboxPeriod?: InboxPeriod
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
}
|
||||
|
||||
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
|
||||
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
|
||||
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
|
||||
|
||||
const typeDocument = useMemo(() => {
|
||||
@@ -154,7 +165,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
|
||||
}
|
||||
}, [typeDocument, persistence])
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
|
||||
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
|
||||
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
|
||||
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
|
||||
|
||||
77
src/components/note-list/noteListUtils.test.ts
Normal file
77
src/components/note-list/noteListUtils.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { routeNoteClick, type ClickActions } from './noteListUtils'
|
||||
import type { VaultEntry } from '../../types'
|
||||
|
||||
function makeEntry(path = '/test.md'): VaultEntry {
|
||||
return {
|
||||
path, filename: 'test.md', title: 'Test', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeActions(): ClickActions {
|
||||
return {
|
||||
onReplace: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
multiSelect: {
|
||||
selectRange: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
setAnchor: vi.fn(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeMouseEvent(overrides: Partial<React.MouseEvent> = {}): React.MouseEvent {
|
||||
return { metaKey: false, ctrlKey: false, shiftKey: false, ...overrides } as React.MouseEvent
|
||||
}
|
||||
|
||||
describe('routeNoteClick', () => {
|
||||
it('plain click replaces active tab', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent(), actions)
|
||||
expect(actions.onReplace).toHaveBeenCalledWith(entry)
|
||||
expect(actions.multiSelect.clear).toHaveBeenCalled()
|
||||
expect(actions.multiSelect.setAnchor).toHaveBeenCalledWith(entry.path)
|
||||
})
|
||||
|
||||
it('Cmd+click opens as new tab', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent({ metaKey: true }), actions)
|
||||
expect(actions.onSelect).toHaveBeenCalledWith(entry)
|
||||
expect(actions.multiSelect.clear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Shift+click selects range', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent({ shiftKey: true }), actions)
|
||||
expect(actions.multiSelect.selectRange).toHaveBeenCalledWith(entry.path)
|
||||
})
|
||||
|
||||
it('Cmd+Shift+click opens in new window', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
|
||||
expect(actions.onOpenInNewWindow).toHaveBeenCalledWith(entry)
|
||||
expect(actions.onReplace).not.toHaveBeenCalled()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+click is a no-op when handler is undefined', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
actions.onOpenInNewWindow = undefined
|
||||
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
|
||||
expect(actions.onReplace).not.toHaveBeenCalled()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: Va
|
||||
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
|
||||
if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash'
|
||||
if (selection.kind === 'filter' && selection.filter === 'changes') return 'Changes'
|
||||
if (selection.kind === 'filter' && selection.filter === 'inbox') return 'Inbox'
|
||||
return 'Notes'
|
||||
}
|
||||
|
||||
@@ -27,11 +28,13 @@ export function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
export interface ClickActions {
|
||||
onReplace: (entry: VaultEntry) => void
|
||||
onSelect: (entry: VaultEntry) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
|
||||
}
|
||||
|
||||
export function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) {
|
||||
if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey) { actions.onOpenInNewWindow?.(entry) }
|
||||
else if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
|
||||
else if (e.metaKey || e.ctrlKey) { actions.multiSelect.clear(); actions.onSelect(entry) }
|
||||
else { actions.multiSelect.clear(); actions.multiSelect.setAnchor(entry.path); actions.onReplace(entry) }
|
||||
}
|
||||
|
||||
@@ -7,23 +7,49 @@ import { updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
|
||||
icon: { icon: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
}
|
||||
|
||||
/** Check if a string contains a wikilink pattern `[[...]]`. */
|
||||
function isWikilink(s: string): boolean {
|
||||
return s.startsWith('[[') && s.includes(']]')
|
||||
}
|
||||
|
||||
/** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */
|
||||
function extractWikilinks(value: FrontmatterValue): string[] {
|
||||
if (typeof value === 'string') return isWikilink(value) ? [value] : []
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v))
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship patch: a partial update to merge into `entry.relationships`.
|
||||
* Keys map to their new ref arrays. A `null` value means "remove this key".
|
||||
*/
|
||||
export type RelationshipPatch = Record<string, string[] | null>
|
||||
|
||||
export interface EntryPatchResult {
|
||||
patch: Partial<VaultEntry>
|
||||
relationshipPatch: RelationshipPatch | null
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
export function frontmatterToEntryPatch(
|
||||
op: 'update' | 'delete', key: string, value?: FrontmatterValue,
|
||||
): Partial<VaultEntry> {
|
||||
): EntryPatchResult {
|
||||
const k = key.toLowerCase().replace(/\s+/g, '_')
|
||||
if (op === 'delete') return ENTRY_DELETE_MAP[k] ?? {}
|
||||
if (op === 'delete') {
|
||||
const relPatch: RelationshipPatch = { [key]: null }
|
||||
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
|
||||
}
|
||||
const str = value != null ? String(value) : null
|
||||
const arr = Array.isArray(value) ? value.map(String) : []
|
||||
const updates: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str },
|
||||
icon: { icon: str }, owner: { owner: str }, cadence: { cadence: str },
|
||||
icon: { icon: str },
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
order: { order: typeof value === 'number' ? value : null },
|
||||
@@ -32,7 +58,11 @@ export function frontmatterToEntryPatch(
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
// Also update the relationships map for wikilink-containing values
|
||||
const wikilinks = value != null ? extractWikilinks(value) : []
|
||||
const relationshipPatch: RelationshipPatch | null =
|
||||
wikilinks.length > 0 ? { [key]: wikilinks } : null
|
||||
return { patch: updates[k] ?? {}, relationshipPatch }
|
||||
}
|
||||
|
||||
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
|
||||
@@ -60,18 +90,53 @@ async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key:
|
||||
return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key)
|
||||
}
|
||||
|
||||
/** Run a frontmatter update/delete and apply the result to state. */
|
||||
export interface FrontmatterOpOptions {
|
||||
/** Suppress toast feedback (caller manages its own toast). */
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
/** Apply a relationship patch by merging into the existing relationships map. */
|
||||
export function applyRelationshipPatch(
|
||||
existing: Record<string, string[]>, relPatch: RelationshipPatch,
|
||||
): Record<string, string[]> {
|
||||
const merged = { ...existing }
|
||||
for (const [k, v] of Object.entries(relPatch)) {
|
||||
if (v === null) delete merged[k]
|
||||
else merged[k] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Run a frontmatter update/delete and apply the result to state.
|
||||
* Returns the new file content on success, or undefined on failure. */
|
||||
export async function runFrontmatterAndApply(
|
||||
op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined,
|
||||
callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial<VaultEntry>) => void; toast: (m: string | null) => void },
|
||||
): Promise<void> {
|
||||
callbacks: {
|
||||
updateTab: (p: string, c: string) => void
|
||||
updateEntry: (p: string, patch: Partial<VaultEntry>) => void
|
||||
toast: (m: string | null) => void
|
||||
getEntry?: (p: string) => VaultEntry | undefined
|
||||
},
|
||||
options?: FrontmatterOpOptions,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
callbacks.updateTab(path, await executeFrontmatterOp(op, path, key, value))
|
||||
const patch = frontmatterToEntryPatch(op, key, value)
|
||||
if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch)
|
||||
callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
|
||||
const newContent = await executeFrontmatterOp(op, path, key, value)
|
||||
callbacks.updateTab(path, newContent)
|
||||
const { patch, relationshipPatch } = frontmatterToEntryPatch(op, key, value)
|
||||
const fullPatch = { ...patch }
|
||||
if (relationshipPatch && callbacks.getEntry) {
|
||||
const current = callbacks.getEntry(path)
|
||||
if (current) {
|
||||
fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch)
|
||||
}
|
||||
}
|
||||
if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch)
|
||||
if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
|
||||
return newContent
|
||||
} catch (err) {
|
||||
console.error(`Failed to ${op} frontmatter:`, err)
|
||||
if (options?.silent) throw err
|
||||
callbacks.toast(`Failed to ${op} property`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
||||
import { useMenuEvents } from './useMenuEvents'
|
||||
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
|
||||
import type { SidebarSelection, SidebarFilter, ThemeFile, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
|
||||
interface Tab { entry: VaultEntry; content: string }
|
||||
@@ -30,6 +31,7 @@ interface AppCommandsConfig {
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onPull?: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
@@ -71,6 +73,12 @@ interface AppCommandsConfig {
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
activeNoteHasIcon?: boolean
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -91,7 +99,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
|
||||
const { onSelect } = config
|
||||
|
||||
const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
const selectFilter = useCallback((filter: SidebarFilter) => {
|
||||
onSelect({ kind: 'filter', filter })
|
||||
}, [onSelect])
|
||||
|
||||
@@ -118,6 +126,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
})
|
||||
@@ -151,6 +160,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCreateTheme: config.onCreateTheme,
|
||||
onRestoreDefaultThemes: config.onRestoreDefaultThemes,
|
||||
onCommitPush: config.onCommitPush,
|
||||
onPull: config.onPull,
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onViewChanges: viewChanges,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
@@ -159,6 +169,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onRepairVault: config.onRepairVault,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -179,6 +190,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onUnarchiveNote: config.onUnarchiveNote,
|
||||
onCommitPush: config.onCommitPush,
|
||||
onPull: config.onPull,
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
@@ -217,6 +229,13 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onSetNoteIcon: config.onSetNoteIcon,
|
||||
onRemoveNoteIcon: config.onRemoveNoteIcon,
|
||||
activeNoteHasIcon: config.activeNoteHasIcon,
|
||||
selection: config.selection,
|
||||
noteListFilter: config.noteListFilter,
|
||||
onSetNoteListFilter: config.onSetNoteListFilter,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -217,4 +217,12 @@ describe('useAppKeyboard', () => {
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Shift+O triggers open in new window', () => {
|
||||
const actions = makeActions()
|
||||
const onOpenInNewWindow = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onOpenInNewWindow }))
|
||||
fireKey('o', { metaKey: true, shiftKey: true })
|
||||
expect(onOpenInNewWindow).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ interface KeyboardActions {
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
}
|
||||
@@ -65,7 +66,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, activeTabPathRef, handleCloseTabRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -107,11 +108,17 @@ export function useAppKeyboard({
|
||||
onReopenClosedTab?.()
|
||||
return
|
||||
}
|
||||
// Cmd+Shift+O: open active note in new window
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'o' || e.key === 'O')) {
|
||||
e.preventDefault()
|
||||
onOpenInNewWindow?.()
|
||||
return
|
||||
}
|
||||
if (!handleViewModeKey(e, onSetViewMode)) {
|
||||
handleCmdKey(e, cmdKeyMap)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow])
|
||||
}
|
||||
|
||||
@@ -107,20 +107,32 @@ describe('useAutoSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pulls on window focus', async () => {
|
||||
it('pulls on window focus after cooldown expires', async () => {
|
||||
const now = vi.spyOn(Date, 'now')
|
||||
let clock = 1000
|
||||
now.mockImplementation(() => clock)
|
||||
|
||||
renderSync()
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
})
|
||||
|
||||
// Focus within cooldown — should NOT trigger pull
|
||||
mockInvokeFn.mockClear()
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
clock += 5_000 // only 5s later
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull')
|
||||
expect(pullCalls).toHaveLength(0)
|
||||
|
||||
// Focus after cooldown — should trigger pull
|
||||
clock += 30_000 // 30s later
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
})
|
||||
|
||||
now.mockRestore()
|
||||
})
|
||||
|
||||
it('triggerSync allows manual pull', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitPullResult, LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
@@ -23,11 +23,16 @@ export interface AutoSyncState {
|
||||
lastSyncTime: number | null
|
||||
conflictFiles: string[]
|
||||
lastCommitInfo: LastCommitInfo | null
|
||||
remoteStatus: GitRemoteStatus | null
|
||||
triggerSync: () => void
|
||||
/** Pull from remote, then push if there are local commits ahead. */
|
||||
pullAndPush: () => void
|
||||
/** Pause auto-pull (e.g. while conflict resolver modal is open). */
|
||||
pausePull: () => void
|
||||
/** Resume auto-pull after pausing. */
|
||||
resumePull: () => void
|
||||
/** Notify that a push was rejected so the status updates to pull_required. */
|
||||
handlePushRejected: () => void
|
||||
}
|
||||
|
||||
export function useAutoSync({
|
||||
@@ -42,11 +47,22 @@ export function useAutoSync({
|
||||
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null)
|
||||
const [conflictFiles, setConflictFiles] = useState<string[]>([])
|
||||
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
|
||||
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
|
||||
const syncingRef = useRef(false)
|
||||
const pauseRef = useRef(false)
|
||||
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
|
||||
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
|
||||
|
||||
const refreshRemoteStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
|
||||
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
@@ -63,6 +79,12 @@ export function useAutoSync({
|
||||
return false
|
||||
}, [vaultPath])
|
||||
|
||||
const refreshCommitInfo = useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
}, [vaultPath])
|
||||
|
||||
const performPull = useCallback(async () => {
|
||||
if (syncingRef.current || pauseRef.current) return
|
||||
syncingRef.current = true
|
||||
@@ -71,9 +93,7 @@ export function useAutoSync({
|
||||
try {
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
setLastSyncTime(Date.now())
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
refreshCommitInfo()
|
||||
|
||||
if (result.status === 'updated') {
|
||||
setSyncStatus('idle')
|
||||
@@ -96,24 +116,95 @@ export function useAutoSync({
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
}
|
||||
|
||||
// Refresh remote status after pull
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts])
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
|
||||
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
|
||||
const pullAndPush = useCallback(async () => {
|
||||
if (syncingRef.current) return
|
||||
syncingRef.current = true
|
||||
setSyncStatus('syncing')
|
||||
|
||||
try {
|
||||
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
|
||||
if (pullResult.status === 'conflict') {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(pullResult.conflictFiles)
|
||||
callbacksRef.current.onConflict(pullResult.conflictFiles)
|
||||
return
|
||||
}
|
||||
|
||||
if (pullResult.status === 'error') {
|
||||
const hasConflicts = await checkExistingConflicts()
|
||||
if (!hasConflicts) {
|
||||
setSyncStatus('error')
|
||||
callbacksRef.current.onToast('Pull failed: ' + pullResult.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pullResult.status === 'updated') {
|
||||
callbacksRef.current.onVaultUpdated()
|
||||
callbacksRef.current.onSyncUpdated?.()
|
||||
}
|
||||
|
||||
// Now push
|
||||
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
|
||||
if (pushResult.status === 'ok') {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
callbacksRef.current.onToast('Pulled and pushed successfully')
|
||||
} else if (pushResult.status === 'rejected') {
|
||||
// Still diverged — shouldn't happen after pull but handle gracefully
|
||||
setSyncStatus('pull_required')
|
||||
callbacksRef.current.onToast('Push still rejected after pull — try again')
|
||||
} else {
|
||||
setSyncStatus('error')
|
||||
callbacksRef.current.onToast(pushResult.message)
|
||||
}
|
||||
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
|
||||
const handlePushRejected = useCallback(() => {
|
||||
setSyncStatus('pull_required')
|
||||
}, [])
|
||||
|
||||
// Check for pre-existing conflicts on mount, then pull
|
||||
useEffect(() => {
|
||||
checkExistingConflicts().then(hasConflicts => {
|
||||
if (!hasConflicts) performPull()
|
||||
})
|
||||
}, [checkExistingConflicts, performPull])
|
||||
refreshRemoteStatus()
|
||||
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
||||
|
||||
// Pull on window focus (app foreground)
|
||||
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
|
||||
const lastPullTimeRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => { performPull() }
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastPullTimeRef.current = now
|
||||
performPull()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [performPull])
|
||||
@@ -128,5 +219,5 @@ export function useAutoSync({
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull, pausePull, resumePull }
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { VaultEntry } from '../types'
|
||||
|
||||
const stubEntry = (path: string): VaultEntry => ({
|
||||
path, filename: path.split('/').pop() ?? '', title: path.split('/').pop()?.replace(/\.md$/, '') ?? '',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: null, cadence: null,
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: 'Active',
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 0, createdAt: 0, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
|
||||
})
|
||||
|
||||
@@ -67,6 +67,40 @@ describe('useCodeMirror', () => {
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('syncs content prop changes to the editor', () => {
|
||||
const ref = { current: container }
|
||||
const onDocChange = vi.fn()
|
||||
const callbacks = { ...noopCallbacks, onDocChange }
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) => useCodeMirror(ref, content, false, callbacks),
|
||||
{ initialProps: { content: '---\ntitle: Hello\n---\nBody' } },
|
||||
)
|
||||
const view = result.current.current!
|
||||
expect(view.state.doc.toString()).toBe('---\ntitle: Hello\n---\nBody')
|
||||
|
||||
// Simulate external content update (e.g. frontmatter written to disk)
|
||||
rerender({ content: '---\ntitle: Hello\nTrashed: true\n---\nBody' })
|
||||
|
||||
expect(view.state.doc.toString()).toBe('---\ntitle: Hello\nTrashed: true\n---\nBody')
|
||||
// External sync should NOT trigger onDocChange (would cause infinite loop)
|
||||
expect(onDocChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not sync when content matches current editor state', () => {
|
||||
const ref = { current: container }
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) => useCodeMirror(ref, content, false, noopCallbacks),
|
||||
{ initialProps: { content: 'hello' } },
|
||||
)
|
||||
const view = result.current.current!
|
||||
const spy = vi.spyOn(view, 'dispatch')
|
||||
|
||||
// Re-render with same content — no dispatch needed
|
||||
rerender({ content: 'hello' })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('installs zoomCursorFix that overrides posAtCoords on the view instance', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -82,6 +82,19 @@ export function useCodeMirror(
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const callbacksRef = useRef(callbacks)
|
||||
callbacksRef.current = callbacks
|
||||
// Track whether we're dispatching an external sync so the updateListener skips it
|
||||
const externalSyncRef = useRef(false)
|
||||
|
||||
// Sync content prop changes to the editor (e.g. after frontmatter update on disk)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const current = view.state.doc.toString()
|
||||
if (current === content) return
|
||||
externalSyncRef.current = true
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: content } })
|
||||
externalSyncRef.current = false
|
||||
}, [content])
|
||||
|
||||
useEffect(() => {
|
||||
const parent = containerRef.current
|
||||
@@ -101,7 +114,7 @@ export function useCodeMirror(
|
||||
frontmatterHighlightPlugin,
|
||||
zoomCursorFix(),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
if (update.docChanged && !externalSyncRef.current) {
|
||||
callbacksRef.current.onDocChange(update.state.doc.toString())
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
@@ -113,6 +126,9 @@ export function useCodeMirror(
|
||||
|
||||
const view = new EditorView({ state, parent })
|
||||
viewRef.current = view
|
||||
// Expose EditorView on the parent DOM for Playwright test access
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(parent as any).__cmView = view
|
||||
|
||||
// When CSS zoom changes on the document, CodeMirror's cached measurements
|
||||
// (scaleX/scaleY, line heights, character widths) become stale because
|
||||
@@ -123,6 +139,8 @@ export function useCodeMirror(
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('laputa-zoom-change', handleZoomChange)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (parent as any).__cmView
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
|
||||
@@ -146,6 +146,51 @@ describe('useCommandRegistry', () => {
|
||||
rerender(makeConfig())
|
||||
expect(findCommand(result.current, 'resolve-conflicts')!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('includes set-note-icon command in Note group', () => {
|
||||
const config = makeConfig({ onSetNoteIcon: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-note-icon')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('Note')
|
||||
expect(cmd!.label).toBe('Set Note Icon')
|
||||
})
|
||||
|
||||
it('set-note-icon is enabled when active note and callback exist', () => {
|
||||
const config = makeConfig({ onSetNoteIcon: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-note-icon')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('set-note-icon is disabled when no active note', () => {
|
||||
const config = makeConfig({ activeTabPath: null, onSetNoteIcon: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-note-icon')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('remove-note-icon is enabled when active note has icon', () => {
|
||||
const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: true })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'remove-note-icon')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('remove-note-icon is disabled when active note has no icon', () => {
|
||||
const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: false })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'remove-note-icon')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('set-note-icon executes callback', () => {
|
||||
const onSetNoteIcon = vi.fn()
|
||||
const config = makeConfig({ onSetNoteIcon })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
findCommand(result.current, 'set-note-icon')!.execute()
|
||||
expect(onSetNoteIcon).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluralizeType', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
|
||||
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Appearance' | 'Settings'
|
||||
@@ -18,6 +19,8 @@ interface CommandRegistryConfig {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
modifiedCount: number
|
||||
/** Whether the active note has an emoji icon set. */
|
||||
activeNoteHasIcon?: boolean
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
@@ -25,6 +28,9 @@ interface CommandRegistryConfig {
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
@@ -38,6 +44,7 @@ interface CommandRegistryConfig {
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onPull?: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
@@ -67,6 +74,10 @@ interface CommandRegistryConfig {
|
||||
onRestoreDefaultThemes?: () => void
|
||||
isGettingStartedHidden?: boolean
|
||||
vaultCount?: number
|
||||
/** Current selection — used to scope filter pill commands to section group views. */
|
||||
selection?: SidebarSelection
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
}
|
||||
|
||||
const PLURAL_OVERRIDES: Record<string, string> = {
|
||||
@@ -191,7 +202,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
@@ -205,8 +216,14 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onReindexVault,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
onSetNoteIcon,
|
||||
onRemoveNoteIcon,
|
||||
activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
selection, noteListFilter, onSetNoteListFilter,
|
||||
} = config
|
||||
|
||||
const isSectionGroup = selection?.kind === 'sectionGroup'
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
|
||||
const activeEntry = useMemo(
|
||||
@@ -228,6 +245,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
|
||||
@@ -247,9 +265,28 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
enabled: hasActiveNote && !!onSetNoteIcon,
|
||||
execute: () => onSetNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
|
||||
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
|
||||
execute: () => onRemoveNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: hasActiveNote,
|
||||
execute: () => onOpenInNewWindow?.(),
|
||||
},
|
||||
|
||||
// Git
|
||||
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
|
||||
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
|
||||
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
@@ -269,10 +306,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ 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?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
|
||||
// Note list filter pills (scoped to section group views)
|
||||
{ id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') },
|
||||
{ id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') },
|
||||
{ id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') },
|
||||
]
|
||||
|
||||
return cmds
|
||||
@@ -280,15 +322,17 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCheckForUpdates,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
onOpenInNewWindow,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ describe('useCommitFlow', () => {
|
||||
let loadModifiedFiles: vi.Mock
|
||||
let commitAndPush: vi.Mock
|
||||
let setToastMessage: vi.Mock
|
||||
let onPushRejected: vi.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
savePending = vi.fn().mockResolvedValue(undefined)
|
||||
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
|
||||
commitAndPush = vi.fn().mockResolvedValue('Committed and pushed')
|
||||
commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' })
|
||||
setToastMessage = vi.fn()
|
||||
onPushRejected = vi.fn()
|
||||
})
|
||||
|
||||
function renderCommitFlow() {
|
||||
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage }))
|
||||
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }))
|
||||
}
|
||||
|
||||
it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => {
|
||||
@@ -46,6 +48,18 @@ describe('useCommitFlow', () => {
|
||||
expect(result.current.showCommitDialog).toBe(false)
|
||||
})
|
||||
|
||||
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
|
||||
commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' })
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitPush('test message')
|
||||
})
|
||||
|
||||
expect(onPushRejected).toHaveBeenCalledTimes(1)
|
||||
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('push rejected'))
|
||||
})
|
||||
|
||||
it('handleCommitPush shows error toast on failure', async () => {
|
||||
commitAndPush.mockRejectedValueOnce(new Error('push failed'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { GitPushResult } from '../types'
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
loadModifiedFiles: () => Promise<void>
|
||||
commitAndPush: (message: string) => Promise<string>
|
||||
commitAndPush: (message: string) => Promise<GitPushResult>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onPushRejected?: () => void
|
||||
}
|
||||
|
||||
/** Manages the Commit & Push dialog state and the save→commit→push flow. */
|
||||
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage }: CommitFlowConfig) {
|
||||
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) {
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
|
||||
const openCommitDialog = useCallback(async () => {
|
||||
@@ -22,13 +24,20 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s
|
||||
try {
|
||||
await savePending()
|
||||
const result = await commitAndPush(message)
|
||||
setToastMessage(result)
|
||||
if (result.status === 'ok') {
|
||||
setToastMessage('Committed and pushed')
|
||||
} else if (result.status === 'rejected') {
|
||||
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
|
||||
onPushRejected?.()
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
loadModifiedFiles()
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${err}`)
|
||||
}
|
||||
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage])
|
||||
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected])
|
||||
|
||||
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
|
||||
@@ -219,6 +219,151 @@ describe('useEditorSave', () => {
|
||||
expect(updateVaultContent).toHaveBeenCalledWith('/vault/note.md', edited)
|
||||
})
|
||||
|
||||
it('calls onNotePersisted with path and content after saving pending content', async () => {
|
||||
const onNotePersisted = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/vault/theme/default.md', '---\nbackground: "#FFD700"\n---\n')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(onNotePersisted).toHaveBeenCalledWith(
|
||||
'/vault/theme/default.md',
|
||||
'---\nbackground: "#FFD700"\n---\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('calls onNotePersisted for unsaved fallback when no pending content', async () => {
|
||||
const onNotePersisted = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
|
||||
)
|
||||
|
||||
// No handleContentChange — simulate Cmd+S on a newly created unsaved note
|
||||
await act(async () => {
|
||||
await result.current.handleSave({ path: '/vault/theme/default.md', content: '---\nbackground: "#FF0000"\n---\n' })
|
||||
})
|
||||
|
||||
expect(onNotePersisted).toHaveBeenCalledWith(
|
||||
'/vault/theme/default.md',
|
||||
'---\nbackground: "#FF0000"\n---\n',
|
||||
)
|
||||
})
|
||||
|
||||
describe('auto-save debounce', () => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('auto-saves 500ms after last content change', async () => {
|
||||
const onNotePersisted = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/test/note.md', 'auto-saved content')
|
||||
})
|
||||
|
||||
// Not saved yet
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
|
||||
// Advance 500ms
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
|
||||
path: '/test/note.md',
|
||||
content: 'auto-saved content',
|
||||
})
|
||||
expect(onNotePersisted).toHaveBeenCalledWith('/test/note.md', 'auto-saved content')
|
||||
})
|
||||
|
||||
it('resets debounce timer on each content change', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'v1') })
|
||||
|
||||
// Advance 400ms (not yet 500ms)
|
||||
await act(async () => { vi.advanceTimersByTime(400) })
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
|
||||
// New edit resets timer
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'v2') })
|
||||
|
||||
// Another 400ms (800ms total, but only 400ms from last edit)
|
||||
await act(async () => { vi.advanceTimersByTime(400) })
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
|
||||
// 100ms more = 500ms from last edit
|
||||
await act(async () => { vi.advanceTimersByTime(100) })
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
|
||||
path: '/test/note.md',
|
||||
content: 'v2',
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-save does not show toast', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+S cancels pending auto-save and saves immediately', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
|
||||
// Cmd+S before debounce fires
|
||||
await act(async () => { await result.current.handleSave() })
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Saved')
|
||||
|
||||
// Advancing timer should NOT cause a second save
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('auto-save calls onAfterSave', async () => {
|
||||
const onAfterSave = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(onAfterSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears auto-save timer on unmount', async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
unmount()
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
// Should not save after unmount
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('successive edits and saves persist each version correctly', async () => {
|
||||
const { result } = renderSaveHook()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { SetStateAction } from 'react'
|
||||
import { useSaveNote } from './useSaveNote'
|
||||
|
||||
@@ -18,13 +18,16 @@ interface EditorSaveConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages explicit save (Cmd+S) for editor content.
|
||||
* Tracks pending (unsaved) content and provides save + pre-rename helpers.
|
||||
* Hook that manages editor content persistence with auto-save.
|
||||
* Content is auto-saved 500ms after the last edit. Cmd+S flushes immediately.
|
||||
*/
|
||||
const noop = () => {}
|
||||
|
||||
const AUTO_SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted }: EditorSaveConfig) {
|
||||
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const updateTabAndContent = useCallback((path: string, content: string) => {
|
||||
updateVaultContent(path, content)
|
||||
@@ -47,9 +50,22 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
return true
|
||||
}, [saveNote, onNotePersisted])
|
||||
|
||||
// Stable ref for onAfterSave so the auto-save timer closure always calls the latest version
|
||||
const onAfterSaveRef = useRef(onAfterSave)
|
||||
useEffect(() => { onAfterSaveRef.current = onAfterSave }, [onAfterSave])
|
||||
|
||||
/** Cancel any pending auto-save timer. */
|
||||
const cancelAutoSave = useCallback(() => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
autoSaveTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Called by Cmd+S — persists the current editor content to disk.
|
||||
* Accepts optional fallback for unsaved notes with no pending edits. */
|
||||
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }) => {
|
||||
cancelAutoSave()
|
||||
try {
|
||||
const saved = await flushPending()
|
||||
if (!saved && unsavedFallback) {
|
||||
@@ -65,26 +81,39 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
console.error('Save failed:', err)
|
||||
setToastMessage(`Save failed: ${err}`)
|
||||
}
|
||||
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
|
||||
}, [cancelAutoSave, flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
|
||||
|
||||
/** Called by Editor onChange — buffers the latest content and syncs tab state
|
||||
* so consumers (e.g. AI panel) always see current editor content. */
|
||||
/** Called by Editor onChange — buffers the latest content, syncs tab state,
|
||||
* and schedules an auto-save after 500ms of inactivity. */
|
||||
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])
|
||||
cancelAutoSave()
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
autoSaveTimerRef.current = null
|
||||
try {
|
||||
const saved = await flushPending()
|
||||
if (saved) onAfterSaveRef.current()
|
||||
} catch (err) {
|
||||
console.error('Auto-save failed:', err)
|
||||
}
|
||||
}, AUTO_SAVE_DEBOUNCE_MS)
|
||||
}, [setTabs, cancelAutoSave, flushPending])
|
||||
|
||||
/** Save pending content for a specific path (used before rename) */
|
||||
// Clear auto-save timer on unmount
|
||||
useEffect(() => () => cancelAutoSave(), [cancelAutoSave])
|
||||
|
||||
/** Save pending content for a specific path (used before rename / tab close) */
|
||||
const savePendingForPath = useCallback(
|
||||
(path: string): Promise<boolean> => flushPending(path),
|
||||
[flushPending],
|
||||
(path: string): Promise<boolean> => { cancelAutoSave(); return flushPending(path) },
|
||||
[cancelAutoSave, flushPending],
|
||||
)
|
||||
|
||||
/** Flush any pending content to disk silently (used before git commit).
|
||||
* Does NOT call onAfterSave — callers manage their own refresh. */
|
||||
const savePending = useCallback((): Promise<boolean> => flushPending(), [flushPending])
|
||||
const savePending = useCallback((): Promise<boolean> => { cancelAutoSave(); return flushPending() }, [cancelAutoSave, flushPending])
|
||||
|
||||
return { handleSave, handleContentChange, savePendingForPath, savePending }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -15,10 +14,6 @@ interface UseEditorTabSwapOptions {
|
||||
activeTabPath: string | null
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
/** Called on every editor change with the H1 text (null if no H1 first block). */
|
||||
onH1Change?: (h1Text: string | null) => void
|
||||
/** When .current is false, handleEditorChange won't update frontmatter title from H1. */
|
||||
syncActiveRef?: React.MutableRefObject<boolean>
|
||||
/** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */
|
||||
rawMode?: boolean
|
||||
}
|
||||
@@ -63,7 +58,7 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
|
||||
*
|
||||
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
|
||||
*/
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef, rawMode }: UseEditorTabSwapOptions) {
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
|
||||
// Cache parsed blocks + scroll position per tab path for instant switching
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
const tabCacheRef = useRef<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
|
||||
@@ -81,8 +76,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
// Keep refs to callbacks for the onChange handler
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
onContentChangeRef.current = onContentChange
|
||||
const onH1ChangeRef = useRef(onH1Change)
|
||||
onH1ChangeRef.current = onH1Change
|
||||
const tabsRef = useRef(tabs)
|
||||
tabsRef.current = tabs
|
||||
|
||||
@@ -122,18 +115,10 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
|
||||
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
const h1Text = getH1TextFromBlocks(blocks)
|
||||
|
||||
// Keep frontmatter title: in sync with H1 when sync is active
|
||||
const isSyncActive = syncActiveRef?.current !== false
|
||||
const fm = (isSyncActive && h1Text)
|
||||
? replaceTitleInFrontmatter(frontmatter, h1Text)
|
||||
: frontmatter
|
||||
const fullContent = `${fm}${bodyMarkdown}`
|
||||
const fullContent = `${frontmatter}${bodyMarkdown}`
|
||||
|
||||
onContentChangeRef.current?.(path, fullContent)
|
||||
onH1ChangeRef.current?.(h1Text)
|
||||
}, [editor, syncActiveRef])
|
||||
}, [editor])
|
||||
|
||||
// Swap document content when active tab changes.
|
||||
// Uses queueMicrotask to defer BlockNote mutations outside React's commit phase,
|
||||
|
||||
@@ -12,8 +12,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
@@ -69,8 +67,8 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleTrashNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', true)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/))
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', true, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
|
||||
trashed: true,
|
||||
trashedAt: expect.any(Number),
|
||||
@@ -78,6 +76,19 @@ describe('useEntryActions', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note moved to trash')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('final toast is contextual, not "Property updated"', async () => {
|
||||
const { result } = setup()
|
||||
const toastCalls: (string | null)[] = []
|
||||
setToastMessage.mockImplementation((msg: string | null) => toastCalls.push(msg))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTrashNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
// The only toast should be "Note moved to trash", never "Property updated"
|
||||
expect(toastCalls).toEqual(['Note moved to trash'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRestoreNote', () => {
|
||||
@@ -88,8 +99,9 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleRestoreNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', false)
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at')
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', { silent: true })
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at', { silent: true })
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
@@ -107,11 +119,23 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleArchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true, { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('final toast is contextual, not "Property updated"', async () => {
|
||||
const { result } = setup()
|
||||
const toastCalls: (string | null)[] = []
|
||||
setToastMessage.mockImplementation((msg: string | null) => toastCalls.push(msg))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleArchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(toastCalls).toEqual(['Note archived'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUnarchiveNote', () => {
|
||||
@@ -122,7 +146,8 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleUnarchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', false)
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'archived', { silent: true })
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
@@ -445,7 +470,7 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
|
||||
it('rolls back restore state when frontmatter write fails', async () => {
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup()
|
||||
|
||||
@@ -463,7 +488,7 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
|
||||
it('rolls back unarchive state when frontmatter write fails', async () => {
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup()
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
|
||||
interface EntryActionsConfig {
|
||||
entries: VaultEntry[]
|
||||
updateEntry: (path: string, updates: Partial<VaultEntry>) => void
|
||||
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise<void>
|
||||
handleDeleteProperty: (path: string, key: string) => Promise<void>
|
||||
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[], options?: FrontmatterOpOptions) => Promise<void>
|
||||
handleDeleteProperty: (path: string, key: string, options?: FrontmatterOpOptions) => Promise<void>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
onFrontmatterPersisted?: () => void
|
||||
@@ -34,8 +35,8 @@ export function useEntryActions({
|
||||
setToastMessage('Note moved to trash')
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, 'Trashed', true)
|
||||
await handleUpdateFrontmatter(path, 'Trashed at', now)
|
||||
await handleUpdateFrontmatter(path, 'Trashed', true, { silent: true })
|
||||
await handleUpdateFrontmatter(path, 'Trashed at', now, { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { trashed: false, trashedAt: null })
|
||||
@@ -49,15 +50,15 @@ export function useEntryActions({
|
||||
updateEntry(path, { trashed: false, trashedAt: null })
|
||||
setToastMessage('Note restored from trash')
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, 'Trashed', false)
|
||||
await handleDeleteProperty(path, 'Trashed at')
|
||||
await handleDeleteProperty(path, 'Trashed', { silent: true })
|
||||
await handleDeleteProperty(path, 'Trashed at', { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
|
||||
setToastMessage('Failed to restore note — rolled back')
|
||||
console.error('Optimistic restore rollback:', err)
|
||||
}
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
}, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleArchiveNote = useCallback(async (path: string) => {
|
||||
await onBeforeAction?.(path)
|
||||
@@ -65,7 +66,7 @@ export function useEntryActions({
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Note archived')
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, 'archived', true)
|
||||
await handleUpdateFrontmatter(path, 'archived', true, { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: false })
|
||||
@@ -79,14 +80,14 @@ export function useEntryActions({
|
||||
updateEntry(path, { archived: false })
|
||||
setToastMessage('Note unarchived')
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, 'archived', false)
|
||||
await handleDeleteProperty(path, 'archived', { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Failed to unarchive note — rolled back')
|
||||
console.error('Optimistic unarchive rollback:', err)
|
||||
}
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
}, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useHeadingTitleSync } from './useHeadingTitleSync'
|
||||
|
||||
describe('useHeadingTitleSync', () => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('calls onTitleSync after debounce when H1 differs from title', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('New Title') })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New Title')
|
||||
})
|
||||
|
||||
it('does not call onTitleSync when H1 matches current title', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Same Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('Same Title') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onTitleSync when H1 is null', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed(null) })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('debounces rapid H1 changes and uses last value', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('New 1') })
|
||||
act(() => { vi.advanceTimersByTime(200) })
|
||||
act(() => { result.current.onH1Changed('New 2') })
|
||||
act(() => { vi.advanceTimersByTime(200) })
|
||||
act(() => { result.current.onH1Changed('New 3') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(onTitleSync).toHaveBeenCalledTimes(1)
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New 3')
|
||||
})
|
||||
|
||||
it('does not call onTitleSync when no active tab', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: null, currentTitle: null, onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('Title') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets sync state when active tab changes', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
|
||||
{ initialProps: { path: '/a.md', title: 'A' } }
|
||||
)
|
||||
|
||||
// Break sync on tab A
|
||||
act(() => { result.current.onManualRename('Custom', 'A H1') })
|
||||
|
||||
// H1 change should be ignored (sync broken)
|
||||
act(() => { result.current.onH1Changed('New A') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
|
||||
// Switch to tab B — sync resets
|
||||
rerender({ path: '/b.md', title: 'B' })
|
||||
|
||||
// H1 change should now work
|
||||
act(() => { result.current.onH1Changed('New B') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/b.md', 'New B')
|
||||
})
|
||||
|
||||
it('breaks sync when manual rename differs from H1', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onManualRename('Custom Name', 'Title') })
|
||||
|
||||
// H1 changes should be ignored
|
||||
act(() => { result.current.onH1Changed('New H1') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not break sync when manual rename matches H1', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onManualRename('Same as H1', 'Same as H1') })
|
||||
|
||||
// Sync should still be active
|
||||
act(() => { result.current.onH1Changed('Updated') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'Updated')
|
||||
})
|
||||
|
||||
it('does not break sync when H1 is null during manual rename', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onManualRename('New Name', null) })
|
||||
|
||||
// Sync should still be active (no H1 to compare)
|
||||
act(() => { result.current.onH1Changed('H1 Text') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'H1 Text')
|
||||
})
|
||||
|
||||
it('clears pending debounce timer on tab switch', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
|
||||
{ initialProps: { path: '/a.md', title: 'A' } }
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('New A') })
|
||||
// Switch tab before debounce fires
|
||||
rerender({ path: '/b.md', title: 'B' })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
|
||||
// Should NOT fire for old tab
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
interface HeadingTitleSyncConfig {
|
||||
activeTabPath: string | null
|
||||
currentTitle: string | null
|
||||
onTitleSync: (path: string, newTitle: string) => void
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 500
|
||||
|
||||
/**
|
||||
* Syncs the note title with the editor's first H1 heading.
|
||||
*
|
||||
* - On every editor change, the caller passes the current H1 text via onH1Changed.
|
||||
* - After a 500ms debounce, VaultEntry.title is updated to match.
|
||||
* - If the user manually renames the title (via tab) to something different from
|
||||
* the H1, sync breaks and stops updating until the tab is switched.
|
||||
* - If the H1 is deleted, the last synced title is kept (no clear).
|
||||
*/
|
||||
export function useHeadingTitleSync({
|
||||
activeTabPath,
|
||||
currentTitle,
|
||||
onTitleSync,
|
||||
}: HeadingTitleSyncConfig) {
|
||||
const syncActiveRef = useRef(true)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const activeTabPathRef = useRef(activeTabPath)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
activeTabPathRef.current = activeTabPath
|
||||
const onTitleSyncRef = useRef(onTitleSync)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
onTitleSyncRef.current = onTitleSync
|
||||
const currentTitleRef = useRef(currentTitle)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
currentTitleRef.current = currentTitle
|
||||
|
||||
// Reset sync state when switching tabs
|
||||
useEffect(() => {
|
||||
syncActiveRef.current = true
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}, [activeTabPath])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => clearTimeout(debounceTimerRef.current), [])
|
||||
|
||||
/** Called on every editor change with the H1 text (null if no H1 first block). */
|
||||
const onH1Changed = useCallback((h1Text: string | null) => {
|
||||
if (!h1Text || !activeTabPathRef.current || !syncActiveRef.current) return
|
||||
if (h1Text === currentTitleRef.current) return
|
||||
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (syncActiveRef.current && activeTabPathRef.current) {
|
||||
onTitleSyncRef.current(activeTabPathRef.current, h1Text)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
}, [])
|
||||
|
||||
/** Called when the user manually renames via tab double-click.
|
||||
* Breaks sync if the new title differs from the current H1. */
|
||||
const onManualRename = useCallback((newTitle: string, currentH1: string | null) => {
|
||||
if (currentH1 && currentH1 !== newTitle) {
|
||||
syncActiveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { syncActiveRef, onH1Changed, onManualRename }
|
||||
}
|
||||
@@ -16,8 +16,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
|
||||
@@ -31,12 +31,14 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onCreateTheme: vi.fn(),
|
||||
onRestoreDefaultThemes: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onReopenClosedTab: vi.fn(),
|
||||
onOpenInNewWindow: 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',
|
||||
@@ -284,6 +286,12 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onCommitPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-pull triggers pull', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-pull', h)
|
||||
expect(h.onPull).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-resolve-conflicts triggers resolve conflicts', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-resolve-conflicts', h)
|
||||
@@ -321,6 +329,13 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onReopenClosedTab).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Note: open in new window
|
||||
it('note-open-in-new-window triggers open in new window', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('note-open-in-new-window', h)
|
||||
expect(h.onOpenInNewWindow).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
|
||||
@@ -32,10 +32,12 @@ export interface MenuEventHandlers {
|
||||
onCreateTheme?: () => void
|
||||
onRestoreDefaultThemes?: () => void
|
||||
onCommitPush?: () => void
|
||||
onPull?: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onViewChanges?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
@@ -75,6 +77,7 @@ const FILTER_MAP: Record<string, SidebarFilter> = {
|
||||
'go-archived': 'archived',
|
||||
'go-trash': 'trash',
|
||||
'go-changes': 'changes',
|
||||
'go-inbox': 'inbox',
|
||||
}
|
||||
|
||||
type OptionalHandler =
|
||||
@@ -82,9 +85,10 @@ type OptionalHandler =
|
||||
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCreateTheme' | 'onRestoreDefaultThemes'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onEmptyTrash'
|
||||
| 'onReopenClosedTab'
|
||||
| 'onOpenInNewWindow'
|
||||
|
||||
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'view-go-back': 'onGoBack',
|
||||
@@ -100,6 +104,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-new-theme': 'onCreateTheme',
|
||||
'vault-restore-default-themes': 'onRestoreDefaultThemes',
|
||||
'vault-commit-push': 'onCommitPush',
|
||||
'vault-pull': 'onPull',
|
||||
'vault-resolve-conflicts': 'onResolveConflicts',
|
||||
'vault-view-changes': 'onViewChanges',
|
||||
'vault-install-mcp': 'onInstallMcp',
|
||||
@@ -108,6 +113,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-repair': 'onRepairVault',
|
||||
'note-empty-trash': 'onEmptyTrash',
|
||||
'file-reopen-closed-tab': 'onReopenClosedTab',
|
||||
'note-open-in-new-window': 'onOpenInNewWindow',
|
||||
}
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
resolveTemplate,
|
||||
} from './useNoteCreation'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { frontmatterToEntryPatch } from './frontmatterOps'
|
||||
import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps'
|
||||
import { useNoteActions } from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -46,8 +46,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
@@ -338,8 +336,6 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['status', 'Done', { status: 'Done' }],
|
||||
['color', 'red', { color: 'red' }],
|
||||
['icon', 'star', { icon: 'star' }],
|
||||
['owner', 'Luca', { owner: 'Luca' }],
|
||||
['cadence', 'Weekly', { cadence: 'Weekly' }],
|
||||
['archived', true, { archived: true }],
|
||||
['trashed', true, { trashed: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
@@ -349,25 +345,45 @@ describe('frontmatterToEntryPatch', () => {
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected)
|
||||
expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('maps aliases update with array value', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] })
|
||||
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B']).patch).toEqual({ aliases: ['A', 'B'] })
|
||||
})
|
||||
|
||||
it('maps belongs_to update with array value', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[parent]]'] })
|
||||
const result = frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])
|
||||
expect(result.patch).toEqual({ belongsTo: ['[[parent]]'] })
|
||||
// Also produces a relationship patch for the wikilink
|
||||
expect(result.relationshipPatch).toEqual({ 'belongs_to': ['[[parent]]'] })
|
||||
})
|
||||
|
||||
it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment')).toEqual({ isA: 'Experiment' })
|
||||
expect(frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])).toEqual({ belongsTo: ['[[x]]'] })
|
||||
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment').patch).toEqual({ isA: 'Experiment' })
|
||||
const result = frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])
|
||||
expect(result.patch).toEqual({ belongsTo: ['[[x]]'] })
|
||||
expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] })
|
||||
})
|
||||
|
||||
it('returns empty object for unknown keys', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({})
|
||||
it('returns empty patch for unknown non-wikilink keys', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'custom_field', 'value')
|
||||
expect(result.patch).toEqual({})
|
||||
// Non-wikilink value → no relationship change
|
||||
expect(result.relationshipPatch).toBeNull()
|
||||
})
|
||||
|
||||
it('produces relationship patch for wikilink values on unknown keys', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]'])
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ Notes: ['[[note-a]]', '[[note-b|Note B]]'] })
|
||||
})
|
||||
|
||||
it('produces relationship patch for single wikilink string', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'Owner', '[[person/alice]]')
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ Owner: ['[[person/alice]]'] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -381,12 +397,46 @@ describe('frontmatterToEntryPatch', () => {
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
expect(frontmatterToEntryPatch('delete', key)).toEqual(expected)
|
||||
expect(frontmatterToEntryPatch('delete', key).patch).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('returns empty object for unknown key on delete', () => {
|
||||
expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({})
|
||||
it('returns empty patch for unknown key on delete, with relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'unknown_key')
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ unknown_key: null })
|
||||
})
|
||||
|
||||
it('delete of known key also produces relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'status')
|
||||
expect(result.patch).toEqual({ status: null })
|
||||
expect(result.relationshipPatch).toEqual({ status: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRelationshipPatch', () => {
|
||||
it('adds new relationship key', () => {
|
||||
const existing = { 'Belongs to': ['[[eng]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: ['[[note-a]]', '[[note-b]]'] })
|
||||
expect(result).toEqual({ 'Belongs to': ['[[eng]]'], Notes: ['[[note-a]]', '[[note-b]]'] })
|
||||
})
|
||||
|
||||
it('overwrites existing relationship key', () => {
|
||||
const existing = { Notes: ['[[old]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: ['[[new-a]]', '[[new-b]]'] })
|
||||
expect(result).toEqual({ Notes: ['[[new-a]]', '[[new-b]]'] })
|
||||
})
|
||||
|
||||
it('removes relationship key when value is null', () => {
|
||||
const existing = { Notes: ['[[a]]'], Owner: ['[[alice]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: null })
|
||||
expect(result).toEqual({ Owner: ['[[alice]]'] })
|
||||
})
|
||||
|
||||
it('does not mutate the original map', () => {
|
||||
const existing = { Notes: ['[[a]]'] }
|
||||
applyRelationshipPatch(existing, { Notes: ['[[b]]'] })
|
||||
expect(existing).toEqual({ Notes: ['[[a]]'] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -933,6 +983,41 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('title sync on reopen', () => {
|
||||
it('calls sync_note_title when reopening an already-open tab', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
const entry = makeEntry({ path: '/test/vault/qa-test.md', filename: 'qa-test.md', title: 'Qa Test' })
|
||||
|
||||
// First open: handleSelectNoteWithSync calls sync, then handleSelectNote calls sync again + loads
|
||||
vi.mocked(invoke)
|
||||
.mockResolvedValueOnce(false) // sync_note_title (from handleSelectNoteWithSync)
|
||||
.mockResolvedValueOnce(false) // sync_note_title (from handleSelectNote)
|
||||
.mockResolvedValueOnce('# Qa Test\n') // get_note_content
|
||||
.mockResolvedValueOnce({ ...entry }) // reload_vault_entry (first open)
|
||||
|
||||
// Second open (reopen, tab already exists): handleSelectNoteWithSync calls sync + reload
|
||||
vi.mocked(invoke)
|
||||
.mockResolvedValueOnce(true) // sync_note_title (reopen — file was modified)
|
||||
.mockResolvedValueOnce('---\ntitle: Qa Test\n---\n# Qa Test\n') // get_note_content (reload)
|
||||
.mockResolvedValueOnce({ ...entry, title: 'Qa Test' }) // reload_vault_entry (reopen)
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([entry])))
|
||||
|
||||
// Open the note (creates tab)
|
||||
await act(async () => { await result.current.handleSelectNote(entry) })
|
||||
|
||||
// Reopen the same note (tab already open — this is the Cmd+P reopen scenario)
|
||||
const desyncedEntry = { ...entry, title: 'Wrong Title Desynced' }
|
||||
await act(async () => { await result.current.handleSelectNote(desyncedEntry) })
|
||||
|
||||
// sync_note_title should have been called for both opens (3 total: 2 for first open, 1 for reopen)
|
||||
const syncCalls = vi.mocked(invoke).mock.calls.filter(c => c[0] === 'sync_note_title')
|
||||
expect(syncCalls.length).toBeGreaterThanOrEqual(2)
|
||||
// Critical: sync was called on reopen (the bug fix)
|
||||
expect(syncCalls.at(-1)).toEqual(['sync_note_title', { path: '/test/vault/qa-test.md' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename note updates wikilinks', () => {
|
||||
it('handleRenameNote passes entry title as old_title to rename_note', async () => {
|
||||
const entry = makeEntry({
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
import { useTabManagement, syncNoteTitle } from './useTabManagement'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import { useNoteCreation } from './useNoteCreation'
|
||||
import {
|
||||
useNoteRename,
|
||||
performRename, loadNoteContent, renameToastMessage, reloadTabsAfterRename,
|
||||
} from './useNoteRename'
|
||||
import { runFrontmatterAndApply } from './frontmatterOps'
|
||||
import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps'
|
||||
|
||||
export interface NoteActionsConfig {
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
@@ -25,6 +27,8 @@ export interface NoteActionsConfig {
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
onNewNotePersisted?: () => void
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
|
||||
onFrontmatterContentChanged?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
function isTitleKey(key: string): boolean {
|
||||
@@ -78,25 +82,48 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
|
||||
}, [setTabs])
|
||||
|
||||
const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote, handleCloseTab, handleCloseTabRef })
|
||||
// After opening a note, reload its VaultEntry so title reflects any sync.
|
||||
const handleSelectNoteWithSync = useCallback(async (entry: VaultEntry) => {
|
||||
// Always sync title with filename — even for already-open tabs.
|
||||
// handleSelectNote skips sync for open tabs (early return), so we call it here first.
|
||||
const wasModified = await syncNoteTitle(entry.path)
|
||||
await handleSelectNote(entry)
|
||||
// Reload entry from disk to pick up title changes from sync_note_title
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const fresh = await invoke<VaultEntry>('reload_vault_entry', { path: entry.path })
|
||||
if (fresh.title !== entry.title) updateEntry(entry.path, { title: fresh.title })
|
||||
// If sync modified the file and tab was already open, refresh tab content
|
||||
if (wasModified) {
|
||||
const content = await loadNoteContent(entry.path)
|
||||
setTabs(prev => prev.map(t => t.entry.path === entry.path
|
||||
? { entry: { ...t.entry, title: fresh.title }, content }
|
||||
: t))
|
||||
}
|
||||
} catch { /* non-fatal: entry display may be stale */ }
|
||||
}
|
||||
}, [handleSelectNote, updateEntry, setTabs])
|
||||
|
||||
const creation = useNoteCreation(config, { openTabWithContent, handleSelectNote: handleSelectNoteWithSync, handleCloseTab, handleCloseTabRef })
|
||||
const rename = useNoteRename(
|
||||
{ entries, setToastMessage },
|
||||
{ tabs: tabMgmt.tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent },
|
||||
)
|
||||
|
||||
const handleNavigateWikilink = useCallback(
|
||||
(target: string) => navigateWikilink(entries, target, handleSelectNote),
|
||||
[entries, handleSelectNote],
|
||||
(target: string) => navigateWikilink(entries, target, handleSelectNoteWithSync),
|
||||
[entries, handleSelectNoteWithSync],
|
||||
)
|
||||
|
||||
const runFrontmatterOp = useCallback(
|
||||
(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue) =>
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage }),
|
||||
[updateTabContent, updateEntry, setToastMessage],
|
||||
(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) =>
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage, entries],
|
||||
)
|
||||
|
||||
return {
|
||||
...tabMgmt,
|
||||
handleSelectNote: handleSelectNoteWithSync,
|
||||
handleCloseTab: creation.handleCloseTabWithCleanup,
|
||||
handleNavigateWikilink,
|
||||
handleCreateNote: creation.handleCreateNote,
|
||||
@@ -105,8 +132,9 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleOpenDailyNote: creation.handleOpenDailyNote,
|
||||
handleCreateType: creation.handleCreateType,
|
||||
createTypeEntrySilent: creation.createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
await runFrontmatterOp('update', path, key, value)
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (shouldRenameOnTitleUpdate(key, value)) {
|
||||
try {
|
||||
await renameAfterTitleChange(path, value, {
|
||||
@@ -117,9 +145,15 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
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]),
|
||||
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ vi.mock('../mock-tauri', () => ({
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/test.md', filename: 'test.md', title: 'Test Note', isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: 'Active', archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '',
|
||||
wordCount: 0, relationships: {}, icon: null, color: null, order: null,
|
||||
outgoingLinks: [], template: null, sort: null, sidebarLabel: null,
|
||||
|
||||
@@ -17,7 +17,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
return {
|
||||
path, filename: `${slug}.md`, title, isA: type,
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
status, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user