Compare commits
25 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f11f3e3ea | ||
|
|
f02bb68021 | ||
|
|
80914e1e3c | ||
|
|
94d07f0d47 | ||
|
|
ef0709a795 | ||
|
|
eac3b123c4 | ||
|
|
25d9da5440 | ||
|
|
e366c84b65 | ||
|
|
7daf6898c1 | ||
|
|
377a3f8d0e | ||
|
|
901326d06f | ||
|
|
d378e488f1 | ||
|
|
e581ad3691 | ||
|
|
09f7498e86 | ||
|
|
7443b9a468 | ||
|
|
12f8fad0f0 | ||
|
|
c90b1d6694 | ||
|
|
806b552d47 | ||
|
|
2cd192fae6 | ||
|
|
46106da47a | ||
|
|
0051559b8e | ||
|
|
3f19528cb6 | ||
|
|
24e5b537d6 | ||
|
|
f8f6e3d003 | ||
|
|
ba9b5c7c31 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.41
|
||||
AVERAGE_THRESHOLD=9.30
|
||||
HOTSPOT_THRESHOLD=9.33
|
||||
AVERAGE_THRESHOLD=9.27
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -55,8 +55,7 @@ After both phases pass, run `/laputa-done <task_id>` → moves to In Review, not
|
||||
|
||||
- Push directly to `main` — no PRs, no branches
|
||||
- Pre-push hook runs full check suite (build + tests + Playwright + CodeScene)
|
||||
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. Never use `--no-verify`.
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
|
||||
|
||||
### TDD (mandatory)
|
||||
|
||||
@@ -68,11 +67,11 @@ Red → Green → Refactor → Commit. One cycle per commit. For bugs: write fai
|
||||
|
||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up, auto-updated after each successful push. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||
|
||||
**Before every commit:**
|
||||
- `mcp__codescene__code_health_review` — check file before touching
|
||||
- `mcp__codescene__code_health_score` — verify score is higher after changes
|
||||
**⛔ NEVER edit `.codescene-thresholds` to lower the values.** If the gate blocks you, improve the code — do not lower the bar.
|
||||
|
||||
**Boy Scout Rule:** every file you touch must leave with a higher score. If Average drops below 9.0, fix regressions before pushing.
|
||||
**Before every commit:** run `mcp__codescene__code_health_review` on files you touched and verify score is higher. **Boy Scout Rule:** every file you touch must leave with a higher score.
|
||||
|
||||
**If CodeScene gate blocks your push:** use `mcp__codescene__code_health_score` to find the worst file, refactor it, commit, push again. Do NOT stop or wait for laputa-refactor — that is a background loop, not a substitute for fixing your own regressions.
|
||||
|
||||
### Check suite (runs on every push)
|
||||
```bash
|
||||
@@ -124,9 +123,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component that does what you need before building a new one. The app already has many reusable pieces — use them.
|
||||
|
||||
**Visual language:** all new UI must feel native to Laputa. Take inspiration from `ui-design.pen` and existing components. If something looks like a browser default, it's wrong.
|
||||
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
@@ -138,7 +135,6 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
|
||||
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
|
||||
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
|
||||
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 343
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 342
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 345
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
7
demo-vault-v2/responsive-rename-test-renamed.md
Normal file
7
demo-vault-v2/responsive-rename-test-renamed.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
4
demo-vault-v2/test-note-abc.md
Normal file
4
demo-vault-v2/test-note-abc.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775473305.md
Normal file
4
demo-vault-v2/untitled-note-1775473305.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-1775473680.md
Normal file
5
demo-vault-v2/untitled-note-1775473680.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
7
demo-vault-v2/untitled-note-1775473969.md
Normal file
7
demo-vault-v2/untitled-note-1775473969.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
6
demo-vault-v2/untitled-note-342.md
Normal file
6
demo-vault-v2/untitled-note-342.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Untitled note 342
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
8
demo-vault-v2/untitled-note-343.md
Normal file
8
demo-vault-v2/untitled-note-343.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 343
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
5
demo-vault-v2/untitled-note-346.md
Normal file
5
demo-vault-v2/untitled-note-346.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 346
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-347.md
Normal file
5
demo-vault-v2/untitled-note-347.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 347
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-348.md
Normal file
5
demo-vault-v2/untitled-note-348.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 348
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
6
demo-vault-v2/untitled-note-349.md
Normal file
6
demo-vault-v2/untitled-note-349.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Untitled note 349
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
5
demo-vault-v2/untitled-note-350.md
Normal file
5
demo-vault-v2/untitled-note-350.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 350
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-351.md
Normal file
5
demo-vault-v2/untitled-note-351.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 351
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
6
demo-vault-v2/untitled-note-352.md
Normal file
6
demo-vault-v2/untitled-note-352.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Untitled note 352
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
8
demo-vault-v2/untitled-note-353-renamed.md
Normal file
8
demo-vault-v2/untitled-note-353-renamed.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 353
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
8
demo-vault-v2/untitled-note-354.md
Normal file
8
demo-vault-v2/untitled-note-354.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 354
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
5
demo-vault-v2/untitled-note-355.md
Normal file
5
demo-vault-v2/untitled-note-355.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 355
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-356.md
Normal file
5
demo-vault-v2/untitled-note-356.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 356
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-357.md
Normal file
5
demo-vault-v2/untitled-note-357.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 357
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
8
demo-vault-v2/untitled-note-359-renamed.md
Normal file
8
demo-vault-v2/untitled-note-359-renamed.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 359
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
5
demo-vault-v2/untitled-note-361.md
Normal file
5
demo-vault-v2/untitled-note-361.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 361
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
15
demo-vault-v2/untitled-project-1775473964.md
Normal file
15
demo-vault-v2/untitled-project-1775473964.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
|
||||
## Key Results
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
16
demo-vault-v2/untitled-project-57.md
Normal file
16
demo-vault-v2/untitled-project-57.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Untitled project 57
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
|
||||
## Key Results
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
16
demo-vault-v2/untitled-project-58.md
Normal file
16
demo-vault-v2/untitled-project-58.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Untitled project 58
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
|
||||
## Key Results
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -77,8 +77,8 @@ classDiagram
|
||||
+Number wordCount
|
||||
+String? snippet
|
||||
+Boolean archived
|
||||
+Boolean trashed
|
||||
+Number? trashedAt
|
||||
+Boolean trashed ⚠ legacy
|
||||
+Number? trashedAt ⚠ legacy
|
||||
+Record~string,string~ properties
|
||||
}
|
||||
|
||||
@@ -127,8 +127,8 @@ interface VaultEntry {
|
||||
wordCount: number | null // Body word count (excludes frontmatter)
|
||||
snippet: string | null // First 200 chars of body
|
||||
archived: boolean // Archived flag
|
||||
trashed: boolean // Trashed flag
|
||||
trashedAt: number | null // When trashed (for auto-purge)
|
||||
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
|
||||
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
}
|
||||
```
|
||||
@@ -248,7 +248,7 @@ The editor displays a dedicated `TitleField` component above the BlockNote edito
|
||||
Navigation state is modeled as a discriminated union:
|
||||
|
||||
```typescript
|
||||
type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse'
|
||||
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
|
||||
@@ -361,7 +361,7 @@ Search is keyword-based, using `walkdir` to scan all `.md` files in the vault di
|
||||
- Matches query against file titles and content (case-insensitive)
|
||||
- Scores results: title matches ranked higher than content-only matches
|
||||
- Extracts contextual snippets around the first match
|
||||
- Skips trashed and hidden files
|
||||
- Skips hidden files
|
||||
|
||||
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
|
||||
|
||||
@@ -466,7 +466,7 @@ sequenceDiagram
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: run_startup_tasks()<br/>(purge trash, register MCP)
|
||||
T->>T: run_startup_tasks()<br/>(register MCP)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>A: App mounts
|
||||
|
||||
@@ -548,7 +548,6 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `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, 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` |
|
||||
@@ -559,7 +558,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, trash, rename, image, migration |
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
@@ -581,14 +580,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Move note to trash |
|
||||
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
|
||||
| `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 |
|
||||
| `empty_trash` | Permanently delete all trashed notes from disk |
|
||||
| `purge_trash` | Delete notes trashed >30 days ago |
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
@@ -857,7 +853,7 @@ Desktop-only features gated at the function level in `commands/`:
|
||||
- Menu state updates
|
||||
|
||||
Features that work on both platforms without changes:
|
||||
- Vault scan, note read/write, rename, delete, trash, archive
|
||||
- Vault scan, note read/write, rename, delete, archive
|
||||
- Frontmatter read/write/delete
|
||||
- AI chat (Anthropic API via `reqwest`)
|
||||
- Search (pure Rust in-memory)
|
||||
|
||||
@@ -138,7 +138,6 @@ laputa-app/
|
||||
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
|
||||
│ │ │ ├── cache.rs # Git-based incremental caching
|
||||
│ │ │ ├── parsing.rs # Text processing + title extraction
|
||||
│ │ │ ├── trash.rs # Trash auto-purge
|
||||
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
|
||||
│ │ │ ├── image.rs # Image attachment saving
|
||||
│ │ │ ├── migration.rs # Frontmatter migration
|
||||
|
||||
@@ -180,7 +180,6 @@ Minimize custom UI behavior per entity type. Everything is a file, everything ge
|
||||
- Favorites
|
||||
- People
|
||||
- Events
|
||||
- Trash
|
||||
|
||||
**Section Groups** (expandable, show entity list):
|
||||
- PROJECTS +
|
||||
@@ -280,7 +279,7 @@ bc75647 Remove unused Vite scaffold files
|
||||
|
||||
### M2: Sidebar & Note List
|
||||
**Goal:** Navigate the vault via sidebar, see filtered note lists.
|
||||
- [ ] Sidebar: Filters section (All Notes, Favorites, Trash)
|
||||
- [ ] Sidebar: Filters section (All Notes, Favorites)
|
||||
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `type:`
|
||||
- [ ] Sidebar: Topics — flat list, populated from `Related to` topic links
|
||||
- [ ] Note list: show title, preview snippet, date, type indicator
|
||||
@@ -315,7 +314,7 @@ bc75647 Remove unused Vite scaffold files
|
||||
**Goal:** Create, rename, delete files. Polish for daily-driver use.
|
||||
- [ ] Create new note (with type selector → sets `type:` in frontmatter, created at vault root)
|
||||
- [ ] Rename file (updates filename + title)
|
||||
- [ ] Delete → move to trash
|
||||
- [ ] Delete → permanent delete with confirm dialog
|
||||
- [ ] Keyboard shortcuts (Cmd+N new, Cmd+S save, Cmd+P quick open/search)
|
||||
- [ ] Quick open palette (Cmd+P) — fuzzy search across all files
|
||||
- [ ] People and Events filters in sidebar
|
||||
|
||||
106
src-tauri/Cargo.lock
generated
106
src-tauri/Cargo.lock
generated
@@ -2282,7 +2282,6 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"trash",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
@@ -4632,7 +4631,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -4721,7 +4720,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4884,7 +4883,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -4953,7 +4952,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4979,7 +4978,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -5405,24 +5404,6 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trash"
|
||||
version = "5.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9b93a14fcf658568eb11b3ac4cb406822e916e2c55cdebc421beeb0bd7c94d8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"scopeguard",
|
||||
"urlencoding",
|
||||
"windows 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.21.3"
|
||||
@@ -5580,12 +5561,6 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -5917,10 +5892,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5941,7 +5916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5991,16 +5966,6 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
|
||||
dependencies = [
|
||||
"windows-core 0.56.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -6023,26 +5988,14 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
|
||||
dependencies = [
|
||||
"windows-implement 0.56.0",
|
||||
"windows-interface 0.56.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -6054,8 +6007,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -6072,17 +6025,6 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -6094,17 +6036,6 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -6149,15 +6080,6 @@ dependencies = [
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -6670,7 +6592,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
|
||||
@@ -37,7 +37,6 @@ tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-opener = "2"
|
||||
sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
trash = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -45,6 +45,16 @@ pub fn rename_note(
|
||||
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: String,
|
||||
note_path: String,
|
||||
) -> Result<Option<RenameResult>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let note_path = expand_tilde(¬e_path);
|
||||
vault::auto_rename_untitled(&vault_path, ¬e_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -60,12 +70,6 @@ pub fn update_wikilinks_for_renames(
|
||||
vault::update_wikilinks_for_renames(&vault_path, &renames)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
@@ -78,12 +82,6 @@ pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
|
||||
vault::batch_delete_notes(&expanded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn empty_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::empty_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -230,23 +228,6 @@ pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "_trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"_trashed_at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── Search commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -297,18 +278,6 @@ mod tests {
|
||||
assert!(content.contains("Status: Active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_trash_notes() {
|
||||
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
|
||||
assert_eq!(
|
||||
batch_trash_notes(vec![note.to_str().unwrap().to_string()]).unwrap(),
|
||||
1
|
||||
);
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("_trashed: true"));
|
||||
assert!(content.contains("_trashed_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_entry_reads_from_disk() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -359,7 +328,7 @@ mod tests {
|
||||
|
||||
std::fs::write(
|
||||
vault_path.join("note.md"),
|
||||
"---\nTrashed: false\n---\n# Note\n",
|
||||
"---\n_archived: false\n---\n# Note\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
@@ -374,11 +343,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap();
|
||||
assert!(!entries[0].trashed);
|
||||
assert!(!entries[0].archived);
|
||||
|
||||
std::fs::write(
|
||||
vault_path.join("note.md"),
|
||||
"---\nTrashed: true\n---\n# Note\n",
|
||||
"---\n_archived: true\n---\n# Note\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -386,8 +355,8 @@ mod tests {
|
||||
crate::vault::invalidate_cache(std::path::Path::new(vp_str));
|
||||
let fresh = crate::vault::scan_vault_cached(std::path::Path::new(vp_str)).unwrap();
|
||||
assert!(
|
||||
fresh[0].trashed,
|
||||
"reload_vault must reflect disk state after trashing"
|
||||
fresh[0].archived,
|
||||
"reload_vault must reflect disk state after archiving"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,15 +16,10 @@ pub mod vault_list;
|
||||
use std::process::Child;
|
||||
#[cfg(desktop)]
|
||||
use std::sync::Mutex;
|
||||
#[cfg(desktop)]
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct LastPurgeTime(Mutex<Instant>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
@@ -34,7 +29,7 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run startup housekeeping on the default vault (purge old trash, migrate legacy frontmatter).
|
||||
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
|
||||
#[cfg(desktop)]
|
||||
fn run_startup_tasks() {
|
||||
let vault_path = dirs::home_dir()
|
||||
@@ -44,10 +39,6 @@ fn run_startup_tasks() {
|
||||
return;
|
||||
}
|
||||
let vp_str = vault_path.to_str().unwrap_or_default();
|
||||
log_startup_result(
|
||||
"Purged trashed files on startup",
|
||||
vault::purge_trash(vp_str).map(|d| d.len()),
|
||||
);
|
||||
log_startup_result(
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
@@ -86,8 +77,6 @@ pub fn run() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(LastPurgeTime(Mutex::new(Instant::now())));
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
@@ -130,6 +119,7 @@ pub fn run() {
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
@@ -158,14 +148,11 @@ pub fn run() {
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::empty_trash,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
@@ -196,39 +183,13 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
match _event {
|
||||
tauri::RunEvent::Exit => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = _app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
if let tauri::RunEvent::Exit = _event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = _app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Focused(true),
|
||||
..
|
||||
} => {
|
||||
let state: tauri::State<'_, LastPurgeTime> = _app_handle.state();
|
||||
let mut last = state.0.lock().unwrap();
|
||||
if last.elapsed() >= std::time::Duration::from_secs(3600) {
|
||||
*last = Instant::now();
|
||||
drop(last);
|
||||
std::thread::spawn(|| {
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
if vault_path.is_dir() {
|
||||
log_startup_result(
|
||||
"Purged trashed files on focus",
|
||||
vault::purge_trash(vault_path.to_str().unwrap_or_default())
|
||||
.map(|d| d.len()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,13 +32,11 @@ const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
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_DELETE: &str = "note-delete";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
|
||||
const VAULT_OPEN: &str = "vault-open";
|
||||
@@ -77,11 +75,9 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
@@ -99,7 +95,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
FILE_SAVE,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_DELETE,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_TOGGLE_BACKLINKS,
|
||||
@@ -249,7 +245,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let archived = MenuItemBuilder::new("Archived")
|
||||
.id(GO_ARCHIVED)
|
||||
.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")
|
||||
@@ -264,7 +259,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
.item(&inbox)
|
||||
.separator()
|
||||
@@ -278,13 +272,10 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let trash_note = MenuItemBuilder::new("Trash Note")
|
||||
.id(NOTE_TRASH)
|
||||
let delete_note = MenuItemBuilder::new("Delete Note")
|
||||
.id(NOTE_DELETE)
|
||||
.accelerator("CmdOrCtrl+Backspace")
|
||||
.build(app)?;
|
||||
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")
|
||||
@@ -295,7 +286,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("CmdOrCtrl+Alt+I")
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
@@ -303,8 +294,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&archive_note)
|
||||
.item(&trash_note)
|
||||
.item(&empty_trash)
|
||||
.item(&delete_note)
|
||||
.separator()
|
||||
.item(&open_new_window)
|
||||
.separator()
|
||||
@@ -462,11 +452,9 @@ mod tests {
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::vault;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
@@ -75,9 +74,6 @@ pub fn search_vault(
|
||||
if !path.extension().is_some_and(|ext| ext == "md") {
|
||||
continue;
|
||||
}
|
||||
if vault::is_file_trashed(path) {
|
||||
continue;
|
||||
}
|
||||
// Skip hidden dirs and .laputa config
|
||||
if path
|
||||
.components()
|
||||
|
||||
@@ -11,7 +11,8 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 11;
|
||||
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
|
||||
const CACHE_VERSION: u32 = 12;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -888,18 +889,18 @@ mod tests {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nTrashed: false\n---\n# Note\n");
|
||||
create_test_file(vault, "note.md", "---\n_archived: false\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache — note is not trashed
|
||||
// Build cache — note is not archived
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(!entries[0].trashed, "note must not be trashed initially");
|
||||
assert!(!entries[0].archived, "note must not be archived initially");
|
||||
|
||||
// Simulate trashing the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\nTrashed: true\n---\n# Note\n");
|
||||
// Simulate archiving the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\n_archived: true\n---\n# Note\n");
|
||||
// Stage the change so git sees it
|
||||
git_add_commit(vault, "trash");
|
||||
git_add_commit(vault, "archive");
|
||||
|
||||
// Without invalidation, scan_vault_cached uses incremental update.
|
||||
// With invalidation, it must do a full rescan from disk.
|
||||
@@ -907,8 +908,8 @@ mod tests {
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert!(
|
||||
entries2[0].trashed,
|
||||
"note must be trashed after invalidate + rescan"
|
||||
entries2[0].archived,
|
||||
"note must be archived after invalidate + rescan"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -936,23 +937,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: `Trashed: Yes` (string) through full cached path.
|
||||
#[test]
|
||||
fn test_cached_vault_trashed_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "trashed-note.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].trashed,
|
||||
"'Trashed: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: stale cache with old version is invalidated and
|
||||
/// re-parses `Archived: Yes` correctly after cache version bump.
|
||||
#[test]
|
||||
|
||||
@@ -26,9 +26,6 @@ pub struct VaultEntry {
|
||||
pub related_to: Vec<String>,
|
||||
pub status: Option<String>,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
#[serde(rename = "trashedAt")]
|
||||
pub trashed_at: Option<u64>,
|
||||
#[serde(rename = "modifiedAt")]
|
||||
pub modified_at: Option<u64>,
|
||||
#[serde(rename = "createdAt")]
|
||||
@@ -81,6 +78,10 @@ pub struct VaultEntry {
|
||||
/// Configured via `_list_properties_display` in the type file's frontmatter.
|
||||
#[serde(rename = "listPropertiesDisplay", default)]
|
||||
pub list_properties_display: Vec<String>,
|
||||
/// Whether the note body has an H1 heading on the first non-empty line.
|
||||
/// Used by the frontend to decide whether to show the TitleField.
|
||||
#[serde(rename = "hasH1")]
|
||||
pub has_h1: bool,
|
||||
/// File kind: "markdown", "text", or "binary".
|
||||
/// Determines how the frontend renders and opens the file.
|
||||
#[serde(rename = "fileKind", default = "default_file_kind")]
|
||||
|
||||
@@ -19,18 +19,8 @@ pub(crate) struct Frontmatter {
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(
|
||||
rename = "_trashed",
|
||||
alias = "Trashed",
|
||||
alias = "trashed",
|
||||
default,
|
||||
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", alias = "trashed_at")]
|
||||
pub trashed_at: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
@@ -147,6 +137,44 @@ impl StringOrList {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a JSON value so that arrays of mixed types (strings + objects + nulls)
|
||||
/// are flattened to arrays of strings. gray_matter mis-parses certain YAML patterns:
|
||||
///
|
||||
/// 1. Unquoted colons in list items: `- Bitcoin: Net Unrealized` becomes
|
||||
/// `{"Bitcoin": "Net Unrealized"}` instead of a plain string.
|
||||
/// 2. Hash comments in list items: `- # Heading` becomes `Null` because
|
||||
/// gray_matter treats `#` as a YAML comment.
|
||||
///
|
||||
/// This sanitizer converts objects back to "key: value" strings and removes nulls,
|
||||
/// preventing serde deserialization of the entire Frontmatter struct from failing.
|
||||
fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
let sanitized: Vec<serde_json::Value> = arr
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
// Drop nulls (from `# comment` in list items)
|
||||
serde_json::Value::Null => None,
|
||||
// Convert mis-parsed objects back to "key: value" strings
|
||||
serde_json::Value::Object(map) if !map.is_empty() => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => format!("{}: {}", k, s),
|
||||
_ => format!("{}: {}", k, v),
|
||||
})
|
||||
.collect();
|
||||
Some(serde_json::Value::String(parts.join(", ")))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Array(sanitized)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse frontmatter from raw YAML data extracted by gray_matter.
|
||||
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
static KNOWN_KEYS: &[&str] = &[
|
||||
@@ -158,12 +186,6 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_archived",
|
||||
"Archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"Trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"Trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
@@ -183,7 +205,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = data
|
||||
.iter()
|
||||
.filter(|(k, _)| KNOWN_KEYS.contains(&k.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.map(|(k, v)| (k.clone(), sanitize_value(v)))
|
||||
.collect();
|
||||
let value = serde_json::Value::Object(filtered);
|
||||
serde_json::from_value(value).unwrap_or_default()
|
||||
@@ -199,11 +221,6 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"aliases",
|
||||
"_archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
|
||||
@@ -539,8 +539,11 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md"), None).unwrap();
|
||||
// No frontmatter title → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "AGENTS");
|
||||
// H1 is now the primary title source
|
||||
assert_eq!(
|
||||
entry.title,
|
||||
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
|
||||
);
|
||||
// Config files have no frontmatter type field — type is None
|
||||
assert_eq!(entry.is_a, None);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,11 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
auto_rename_untitled, detect_renames, rename_note, update_wikilinks_for_renames,
|
||||
DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
pub use views::{
|
||||
delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode,
|
||||
FilterOp, ViewDefinition, ViewFile,
|
||||
@@ -57,6 +58,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
|
||||
|
||||
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
|
||||
let has_h1 = parsing::extract_h1_title(&content).is_some();
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
@@ -98,12 +100,6 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
related_to,
|
||||
status: frontmatter.status.and_then(|v| v.into_scalar()),
|
||||
archived: frontmatter.archived.unwrap_or(false),
|
||||
trashed: frontmatter.trashed.unwrap_or(false),
|
||||
trashed_at: frontmatter
|
||||
.trashed_at
|
||||
.and_then(|v| v.into_scalar())
|
||||
.as_deref()
|
||||
.and_then(parsing::parse_iso_date),
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
@@ -122,6 +118,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
has_h1,
|
||||
file_kind: "markdown".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,8 +94,8 @@ fn test_parse_empty_frontmatter() {
|
||||
"just-a-title.md",
|
||||
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
|
||||
);
|
||||
// No title in frontmatter → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "Just A Title");
|
||||
// H1 is now the primary title source
|
||||
assert_eq!(entry.title, "Just a Title");
|
||||
assert!(entry.aliases.is_empty());
|
||||
|
||||
assert!(entry.belongs_to.is_empty());
|
||||
@@ -955,30 +955,6 @@ Company: Acme Corp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_title_case() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: true\nTrashed at: \"2025-02-01\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone.md", content);
|
||||
assert!(entry.trashed);
|
||||
assert!(entry.trashed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: true\ntrashed_at: \"2025-02-01\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"lowercase 'trashed' must be parsed via alias"
|
||||
);
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"lowercase 'trashed_at' must be parsed via alias"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -998,16 +974,7 @@ fn test_parse_archived_titlecase() {
|
||||
assert!(entry.archived, "titlecase 'Archived' must also be parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_false_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active.md", content);
|
||||
assert!(!entry.trashed);
|
||||
assert!(entry.trashed_at.is_none());
|
||||
}
|
||||
|
||||
// --- archived/trashed string-value tests ---
|
||||
// --- archived string-value tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_titlecase() {
|
||||
@@ -1068,44 +1035,8 @@ fn test_parse_archived_absent() {
|
||||
assert!(!entry.archived, "absent archived must default to false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: Yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone2.md", content);
|
||||
assert!(entry.trashed, "'Trashed: Yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone3.md", content);
|
||||
assert!(entry.trashed, "'trashed: yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_no() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: No\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active6.md", content);
|
||||
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
|
||||
}
|
||||
|
||||
// --- new canonical underscore-prefixed keys ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_trashed_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_trashed: true\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone-new.md", content);
|
||||
assert!(entry.trashed, "'_trashed: true' must be parsed as trashed");
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"'_trashed_at' must be parsed as trashed_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_archived_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1166,22 +1097,6 @@ fn test_parse_visible_missing_defaults_to_none() {
|
||||
assert_eq!(entry.visible, None);
|
||||
}
|
||||
|
||||
// --- Regression: trashed/archived must survive unquoted date in "Trashed at" ---
|
||||
|
||||
#[test]
|
||||
fn test_trashed_true_with_unquoted_date_in_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Reproduces the engineering-management.md scenario: Trashed at has an
|
||||
// unquoted YAML date (2026-03-11) which gray_matter may parse as a non-string.
|
||||
// The entire Frontmatter deserialization must NOT fail because of this.
|
||||
let content = "---\ntype: Topic\nTrashed: true\n\"Trashed at\": 2026-03-11\n---\n# Engineering Management\n";
|
||||
let entry = parse_test_entry(&dir, "engineering-management.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when 'Trashed at' contains an unquoted date"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archived_true_with_extra_non_string_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1194,91 +1109,6 @@ fn test_archived_true_with_extra_non_string_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_with_reviewed_false_field() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Topic\nReviewed: False\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "reviewed-test.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when frontmatter contains Reviewed: False"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
/// Regression: wikilinks containing curly braces + nested quotes in YAML arrays
|
||||
/// cause gray_matter to produce Hash values instead of strings for array elements.
|
||||
/// This must NOT make parse_frontmatter fall back to default (losing trashed/archived).
|
||||
#[test]
|
||||
fn test_trashed_survives_malformed_wikilinks_in_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// This YAML has curly braces inside a double-quoted string, producing nested
|
||||
// Hash values in some YAML parsers. The Frontmatter serde must not fail.
|
||||
let content = "---\ntype: Topic\nNotes:\n - \"[[foo|bar]]\"\n - \"[[slug|{'Title': 'Subtitle'}]]\"\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "malformed-links.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even with curly-brace wikilinks in frontmatter arrays"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: files with malformed YAML (e.g. Notion exports with unescaped quotes
|
||||
/// in wikilinks) cause gray_matter to return Null instead of a Hash. The fallback
|
||||
/// parser must still extract Trashed, type, and other simple key:value fields.
|
||||
#[test]
|
||||
fn test_parse_real_engineering_management_file() {
|
||||
let path = std::path::Path::new("/Users/luca/Laputa/engineering-management.md");
|
||||
if !path.exists() {
|
||||
return; // Skip when the Laputa vault is not available
|
||||
}
|
||||
let entry = parse_md_file(path, None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Simulate malformed YAML that gray_matter can't parse: unescaped double
|
||||
// quotes inside a double-quoted YAML string cause the YAML parser to fail.
|
||||
// The fallback line-by-line parser must still extract simple key:value pairs.
|
||||
//
|
||||
// Write the file manually with literal unescaped quotes (can't use Rust string
|
||||
// escaping for this since the YAML itself is the malformed part).
|
||||
let fm = [
|
||||
"---",
|
||||
"type: Topic",
|
||||
"Status: Draft",
|
||||
"Belongs to:",
|
||||
" - \"[[engineering|Engineering]]\"",
|
||||
"aliases:",
|
||||
" - Engineering Management",
|
||||
"Notes:",
|
||||
// This line has unescaped " inside a "-quoted YAML string — malformed YAML
|
||||
" - \"[[slug|{\"Title\": 'Subtitle'}]]\"",
|
||||
"Trashed: true",
|
||||
"\"Trashed at\": 2026-03-11",
|
||||
"---",
|
||||
"",
|
||||
"# Engineering Management",
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Topic".to_string()),
|
||||
"isA must be extracted by fallback parser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1521,6 +1351,391 @@ fn test_non_yml_file_uses_filename_as_title() {
|
||||
assert_eq!(entry.title, "notes.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_file_kind_yml_is_text() {
|
||||
assert_eq!(
|
||||
classify_file_kind(Path::new("views/active-projects.yml")),
|
||||
"text"
|
||||
);
|
||||
assert_eq!(classify_file_kind(Path::new("config.yaml")), "text");
|
||||
assert_eq!(classify_file_kind(Path::new("data.json")), "text");
|
||||
assert_eq!(classify_file_kind(Path::new("script.py")), "text");
|
||||
assert_eq!(classify_file_kind(Path::new("readme.txt")), "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_file_kind_md_is_markdown() {
|
||||
assert_eq!(classify_file_kind(Path::new("note.md")), "markdown");
|
||||
assert_eq!(classify_file_kind(Path::new("README.markdown")), "markdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_file_kind_unknown_is_binary() {
|
||||
assert_eq!(classify_file_kind(Path::new("photo.png")), "binary");
|
||||
assert_eq!(classify_file_kind(Path::new("archive.zip")), "binary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_md_file_gets_text_file_kind() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"views/my-view.yml",
|
||||
"name: My View\nicon: rocket\n",
|
||||
);
|
||||
let entry = super::parse_non_md_file(&dir.path().join("views/my-view.yml"), None).unwrap();
|
||||
assert_eq!(entry.file_kind, "text");
|
||||
assert_eq!(entry.title, "My View");
|
||||
}
|
||||
|
||||
/// Regression test: notes with complex frontmatter (quoted values, wikilinks
|
||||
/// in arrays, Notion-imported fields) must parse type and _organized correctly.
|
||||
/// Bug: initial load showed Type: None and notes appeared in Inbox despite
|
||||
/// having type and _organized: true in frontmatter.
|
||||
#[test]
|
||||
fn test_complex_frontmatter_type_and_organized() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Notion-style frontmatter with quoted dates, wikilinks in arrays, non-standard fields
|
||||
let content = r#"---
|
||||
type: Note
|
||||
_organized: true
|
||||
aliases: ["My Complex Note"]
|
||||
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
|
||||
"Created at": "2021-12-31T14:19:00.000Z"
|
||||
Status: Published
|
||||
Owner: "[[person-luca|Luca]]"
|
||||
---
|
||||
# My Complex Note
|
||||
|
||||
Content here.
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "complex-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed correctly with complex frontmatter"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true with complex frontmatter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_frontmatter_is_a_alias_with_extra_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Uses "Is A" instead of "type", plus many non-standard fields
|
||||
let content = r#"---
|
||||
Is A: Evergreen
|
||||
_organized: true
|
||||
aliases: ["Writing for Clarity vs. Writing for Credit"]
|
||||
Topics: ["[[topic-writing]]"]
|
||||
Status: Published
|
||||
"Last edited": "2024-06-15T10:30:00.000Z"
|
||||
notion_id: "abc123def456"
|
||||
---
|
||||
# Writing for Clarity vs. Writing for Credit
|
||||
|
||||
Content.
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "writing-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Evergreen".to_string()),
|
||||
"Is A must be parsed correctly with quoted keys nearby"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even with complex surrounding fields"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_frontmatter_boolean_as_yaml_yes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// _organized as YAML boolean Yes (not true)
|
||||
let content = "---\ntype: Note\n_organized: Yes\nStatus: Draft\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "yes-note.md", content);
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
assert!(entry.organized, "_organized: Yes must be parsed as true");
|
||||
}
|
||||
|
||||
/// Regression: gray_matter may fail to parse YAML with unquoted timestamp values
|
||||
/// (colons in values like "14:19:00"), causing the entire frontmatter to be lost.
|
||||
#[test]
|
||||
fn test_complex_frontmatter_unquoted_timestamp() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let content = "---\ntype: Note\n_organized: true\nCreated at: 2021-12-31T14:19:00.000Z\nTopics:\n - \"[[topic-writing]]\"\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "timestamp-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must survive unquoted timestamp in sibling field"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must survive unquoted timestamp in sibling field"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: gray_matter may fail with flow-style arrays containing wikilinks
|
||||
/// whose brackets look like nested YAML structures.
|
||||
#[test]
|
||||
fn test_complex_frontmatter_flow_array_with_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let content = r#"---
|
||||
type: Evergreen
|
||||
_organized: true
|
||||
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
|
||||
Has: ["[[note-one]]", "[[note-two]]", "[[note-three]]"]
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "flow-array.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Evergreen".to_string()),
|
||||
"type must survive flow arrays with wikilinks"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must survive flow arrays with wikilinks"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: YAML that gray_matter cannot parse at all (returns Pod::String)
|
||||
/// must still extract type and _organized via fallback parser.
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_type_and_organized() {
|
||||
use super::frontmatter::extract_fm_and_rels;
|
||||
// Simulate gray_matter returning String (parse failure) by passing None
|
||||
let raw_content =
|
||||
"---\ntype: Note\n_organized: true\nBroken: value: with: colons\n---\n# Test\n";
|
||||
let (fm, _, _) = extract_fm_and_rels(None, raw_content);
|
||||
assert_eq!(
|
||||
fm.is_a.as_ref().and_then(|v| v.clone().into_scalar()),
|
||||
Some("Note".to_string()),
|
||||
"fallback parser must extract type"
|
||||
);
|
||||
assert_eq!(
|
||||
fm.organized,
|
||||
Some(true),
|
||||
"fallback parser must extract _organized"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: real-world Notion-imported note with quoted keys containing
|
||||
/// special characters (?, spaces) and many non-standard fields.
|
||||
#[test]
|
||||
fn test_notion_imported_frontmatter_with_special_quoted_keys() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
type: Readings
|
||||
aliases:
|
||||
- "1 to 1s"
|
||||
"Discarded for digest?": false
|
||||
"Note Status": Saved
|
||||
URL: "http://theengineeringmanager.com/management-101/121s/"
|
||||
Author: James Stanier
|
||||
Category: Articles
|
||||
"Full Title": 1 to 1s
|
||||
Highlights: 21
|
||||
"Last Synced": 2025-12-10
|
||||
"Last Highlighted": 2021-04-12
|
||||
notion_id: 2c5bdf02-815c-81ce-9dce-eca60ddaeb08
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# 1 to 1s
|
||||
|
||||
Content.
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "1-to-1s.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Readings".to_string()),
|
||||
"type must be parsed with Notion-style quoted keys"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true with Notion-style frontmatter"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: unquoted colon in YAML list item causes gray_matter to mis-parse.
|
||||
/// `aliases:\n - Bitcoin: Net Unrealized...` has an unquoted `:` in a list item.
|
||||
/// gray_matter may return a partial/mangled Hash instead of failing cleanly.
|
||||
#[test]
|
||||
fn test_unquoted_colon_in_list_item_breaks_parsing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n_organized: true\naliases:\n - Bitcoin: Net Unrealized Profit/Loss\n - Note\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "colon-alias.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed correctly even when gray_matter fails due to unquoted colon in list item"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even when gray_matter fails due to unquoted colon in list item"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: `# ` in a YAML list item is interpreted as a comment by gray_matter,
|
||||
/// truncating the frontmatter and losing subsequent fields.
|
||||
#[test]
|
||||
fn test_hash_in_list_item_treated_as_comment() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n_organized: true\naliases:\n - # Writing a Good CLAUDE.md\n - Note\n---\n# Title\n";
|
||||
let entry = parse_test_entry(&dir, "hash-alias.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed even when alias starts with # (YAML comment char)"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even when alias starts with # (YAML comment char)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full reproduction: real-world Notion-imported note with unquoted colon in alias.
|
||||
#[test]
|
||||
fn test_real_world_notion_note_with_unquoted_colon() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
type: Note
|
||||
workspace: personal
|
||||
notion_id: de48a4ad-e7ad-42aa-a5ce-1efdc259d7f9
|
||||
"Created at": "2021-12-17T10:21:00.000Z"
|
||||
Reviewed: True
|
||||
Topics:
|
||||
- "[[web3|Web3 / Crypto]]"
|
||||
URL: https://studio.glassnode.com/metrics?a=BTC&category=Market%20Indicators&m=indicators.NetUnrealizedProfitLoss&s=1320105600&u=1639267199&zoom=
|
||||
aliases:
|
||||
- Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
|
||||
- Note
|
||||
Trashed: true
|
||||
"Trashed at": 2026-03-11
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
|
||||
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "bitcoin-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed correctly even with unquoted colon in alias"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even with unquoted colon in alias"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scan the real user vault and verify all notes with type/organized in frontmatter
|
||||
/// have those fields correctly parsed.
|
||||
#[test]
|
||||
fn test_real_vault_type_and_organized_consistency() {
|
||||
let vault_path = std::path::Path::new("/Users/luca/Laputa");
|
||||
if !vault_path.exists() {
|
||||
eprintln!("Skipping: ~/Laputa vault not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut mismatches = Vec::new();
|
||||
let walker = walkdir::WalkDir::new(vault_path)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !e.file_name().to_string_lossy().starts_with('.'));
|
||||
|
||||
for dir_entry in walker.filter_map(|e| e.ok()) {
|
||||
let path = dir_entry.path();
|
||||
if !path.is_file() || path.extension().is_none_or(|ext| ext != "md") {
|
||||
continue;
|
||||
}
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Only check files that have frontmatter
|
||||
if !content.starts_with("---\n") {
|
||||
continue;
|
||||
}
|
||||
let fm_end = match content[4..].find("\n---") {
|
||||
Some(pos) => pos + 4,
|
||||
None => continue,
|
||||
};
|
||||
let fm_block = &content[4..fm_end];
|
||||
|
||||
// Check if frontmatter has type with a non-empty value
|
||||
let has_type_with_value = fm_block.lines().any(|l| {
|
||||
let trimmed = l.trim();
|
||||
for prefix in &["type:", "Is A:", "is_a:"] {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
||||
return !rest.trim().is_empty();
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
// Check if frontmatter has _organized: true
|
||||
let has_organized_true = fm_block.lines().any(|l| {
|
||||
let t = l.trim();
|
||||
t == "_organized: true"
|
||||
|| t == "_organized: True"
|
||||
|| t == "_organized: yes"
|
||||
|| t == "_organized: Yes"
|
||||
});
|
||||
|
||||
if !has_type_with_value && !has_organized_true {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed = match parse_md_file(path, None) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
mismatches.push(format!("PARSE ERROR: {} -> {}", path.display(), err));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if has_type_with_value && parsed.is_a.is_none() {
|
||||
mismatches.push(format!(
|
||||
"TYPE MISSING: {} (raw has type with value but parsed isA=None)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if has_organized_true && !parsed.organized {
|
||||
mismatches.push(format!(
|
||||
"ORGANIZED MISSING: {} (raw has _organized: true but parsed organized=false)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !mismatches.is_empty() {
|
||||
let summary = mismatches
|
||||
.iter()
|
||||
.take(20)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
panic!(
|
||||
"Found {} parsing mismatches in real vault:\n{}",
|
||||
mismatches.len(),
|
||||
summary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -21,15 +21,40 @@ pub(super) fn slug_to_title(stem: &str) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Extract the H1 title from the first non-empty line of the body (after frontmatter).
|
||||
/// Returns `None` if no H1 is found on the first non-empty line.
|
||||
pub(super) fn extract_h1_title(content: &str) -> Option<String> {
|
||||
let body = strip_frontmatter(content);
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(title) = trimmed.strip_prefix("# ") {
|
||||
let title = strip_markdown_chars(title).trim().to_string();
|
||||
if !title.is_empty() {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
break; // first non-empty line is not H1
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title.
|
||||
pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String {
|
||||
// 1. H1 on first non-empty line of body
|
||||
if let Some(h1) = extract_h1_title(content) {
|
||||
return h1;
|
||||
}
|
||||
// 2. frontmatter title (legacy, backward compat)
|
||||
if let Some(title) = fm_title {
|
||||
if !title.is_empty() {
|
||||
return title.to_string();
|
||||
}
|
||||
}
|
||||
// 3. filename slug
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
slug_to_title(stem)
|
||||
}
|
||||
@@ -285,32 +310,6 @@ pub(super) fn extract_outgoing_links(content: &str) -> Vec<String> {
|
||||
links
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
|
||||
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
|
||||
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
let trimmed = date_str.trim().trim_matches('"');
|
||||
|
||||
// Try full datetime with optional fractional seconds and Z suffix
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.fZ") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
|
||||
// Try date-only
|
||||
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
||||
return Some(d.and_hms_opt(0, 0, 0)?.and_utc().timestamp() as u64);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -342,29 +341,72 @@ mod tests {
|
||||
assert_eq!(slug_to_title("a--b"), "A B");
|
||||
}
|
||||
|
||||
// --- extract_h1_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_basic() {
|
||||
assert_eq!(
|
||||
extract_h1_title("# Hello World\n\nBody."),
|
||||
Some("Hello World".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_after_frontmatter() {
|
||||
let content = "---\ntype: Note\n---\n# My Note\n\nBody.";
|
||||
assert_eq!(extract_h1_title(content), Some("My Note".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_with_empty_lines_before() {
|
||||
let content = "---\ntype: Note\n---\n\n# Spaced Title\n\nBody.";
|
||||
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_none_when_no_h1() {
|
||||
assert_eq!(extract_h1_title("Just body text."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_none_when_h1_not_first() {
|
||||
assert_eq!(extract_h1_title("Some text\n# Not first\n"), None);
|
||||
}
|
||||
|
||||
// --- extract_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_from_frontmatter() {
|
||||
fn test_extract_title_h1_takes_priority_over_frontmatter() {
|
||||
assert_eq!(
|
||||
extract_title(Some("My Great Note"), "", "my-great-note.md"),
|
||||
"My Great Note"
|
||||
extract_title(
|
||||
Some("FM Title"),
|
||||
"---\ntitle: FM Title\n---\n# H1 Title\n\nBody.",
|
||||
"note.md"
|
||||
),
|
||||
"H1 Title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_ignores_h1_uses_filename() {
|
||||
// H1 is body content, not a title source
|
||||
fn test_extract_title_h1_when_no_frontmatter_title() {
|
||||
assert_eq!(
|
||||
extract_title(None, "# Hello World\n\nBody text.", "some-file.md"),
|
||||
"Some File"
|
||||
"Hello World"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_ignores_h1_after_frontmatter() {
|
||||
fn test_extract_title_h1_after_frontmatter() {
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nBody.";
|
||||
assert_eq!(extract_title(None, content, "fallback.md"), "Fallback");
|
||||
assert_eq!(extract_title(None, content, "fallback.md"), "My Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_frontmatter_when_no_h1() {
|
||||
assert_eq!(
|
||||
extract_title(Some("My Great Note"), "Just body text.", "my-great-note.md"),
|
||||
"My Great Note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -376,11 +418,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_empty_fm_falls_back_to_filename() {
|
||||
// Empty frontmatter title falls back to filename, not H1
|
||||
fn test_extract_title_h1_wins_over_empty_frontmatter() {
|
||||
assert_eq!(
|
||||
extract_title(Some(""), "# From H1\n", "empty-h1.md"),
|
||||
"Empty H1"
|
||||
"From H1"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -747,50 +788,6 @@ mod tests {
|
||||
assert!(!contains_wikilink("only ]] closing"));
|
||||
}
|
||||
|
||||
// --- parse_iso_date tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_full_datetime_with_z() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00.000Z");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_datetime_no_fractional() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00Z");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_datetime_no_z() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_date_only() {
|
||||
let ts = parse_iso_date("2025-05-23");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1747958400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_with_quotes_and_whitespace() {
|
||||
let ts = parse_iso_date(" \"2025-05-23\" ");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1747958400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_invalid() {
|
||||
assert!(parse_iso_date("not-a-date").is_none());
|
||||
assert!(parse_iso_date("").is_none());
|
||||
assert!(parse_iso_date("2025-13-45").is_none());
|
||||
}
|
||||
|
||||
// --- extract_outgoing_links tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -251,6 +251,46 @@ pub fn rename_note(
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
|
||||
fn is_untitled_filename(filename: &str) -> bool {
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
// Match: untitled-note-{digits} or untitled-{type}-{digits}
|
||||
stem.starts_with("untitled-")
|
||||
&& stem
|
||||
.rsplit('-')
|
||||
.next()
|
||||
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// Auto-rename an untitled note based on its H1 heading.
|
||||
/// Returns `Some(RenameResult)` if renamed, `None` if conditions not met.
|
||||
/// This is a ONE-SHOT rename: only fires for untitled-* files with an H1.
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: &str,
|
||||
note_path: &str,
|
||||
) -> Result<Option<RenameResult>, String> {
|
||||
let path = Path::new(note_path);
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !is_untitled_filename(&filename) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
|
||||
let h1_title = match super::parsing::extract_h1_title(&content) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let result = rename_note(vault_path, note_path, &h1_title, None)?;
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
/// A detected rename: old path → new path (both relative to vault root).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DetectedRename {
|
||||
|
||||
@@ -1,128 +1,5 @@
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use std::fs;
|
||||
use std::io::Write as IoWrite;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Check if a file path points to a markdown file.
|
||||
fn is_markdown_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
/// Extract the "Trashed at" date string from parsed gray_matter data.
|
||||
fn extract_trashed_at_string(data: &Option<gray_matter::Pod>) -> Option<String> {
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref()? else {
|
||||
return None;
|
||||
};
|
||||
let pod = map
|
||||
.get("_trashed_at")
|
||||
.or_else(|| map.get("Trashed at"))
|
||||
.or_else(|| map.get("trashed_at"))?;
|
||||
match pod {
|
||||
gray_matter::Pod::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a "Trashed at" date string into a NaiveDate. Supports "2026-01-01" and "2026-01-01T..." formats.
|
||||
fn parse_trashed_date(date_str: &str) -> Option<chrono::NaiveDate> {
|
||||
let trimmed = date_str.trim().trim_matches('"');
|
||||
let date_part = trimmed.split('T').next().unwrap_or(trimmed);
|
||||
chrono::NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
/// Check if `_trashed` (or aliases) is set to a truthy value in gray_matter data.
|
||||
fn is_trashed_flag_set(data: &Option<gray_matter::Pod>) -> bool {
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref().unwrap_or(&gray_matter::Pod::Null) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true" | "1")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a file path is strictly inside the given vault root.
|
||||
fn is_inside_vault(file_path: &Path, vault_root: &Path) -> bool {
|
||||
match (file_path.canonicalize(), vault_root.canonicalize()) {
|
||||
(Ok(fp), Ok(vr)) => fp.starts_with(&vr),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a file to OS trash. Falls back to fs::remove_file if OS trash fails.
|
||||
fn trash_or_remove(path: &Path) -> Result<(), String> {
|
||||
match trash::delete(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(trash_err) => {
|
||||
log::warn!(
|
||||
"OS trash failed for {}: {} — falling back to fs::remove_file",
|
||||
path.display(),
|
||||
trash_err
|
||||
);
|
||||
fs::remove_file(path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a file (move to OS trash) and log the result. Returns the path string if successful.
|
||||
fn try_purge_file(path: &Path) -> Option<String> {
|
||||
match trash_or_remove(path) {
|
||||
Ok(()) => {
|
||||
log::info!("Purged trashed file: {}", path.display());
|
||||
Some(path.to_string_lossy().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to purge {}: {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a purge run summary to `.laputa/purge.log`.
|
||||
fn write_purge_log(vault_path: &Path, checked: usize, purged: &[String], dry_run: bool) {
|
||||
let log_dir = vault_path.join(".laputa");
|
||||
if fs::create_dir_all(&log_dir).is_err() {
|
||||
log::warn!("Could not create .laputa/ directory for purge log");
|
||||
return;
|
||||
}
|
||||
let log_path = log_dir.join("purge.log");
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("Could not open purge.log: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
let mode = if dry_run { " [DRY-RUN]" } else { "" };
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"[{}]{} checked={}, purged={}",
|
||||
now,
|
||||
mode,
|
||||
checked,
|
||||
purged.len()
|
||||
);
|
||||
for path in purged {
|
||||
let _ = writeln!(file, " - {}", path);
|
||||
}
|
||||
}
|
||||
use std::path::Path;
|
||||
|
||||
/// Permanently delete a single note file.
|
||||
/// Returns the deleted path on success, or an error if the file doesn't exist.
|
||||
@@ -139,41 +16,6 @@ pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// Check whether a file's frontmatter marks it as trashed.
|
||||
/// Returns `true` if `Trashed: true` or `Trashed at` is present.
|
||||
pub fn is_file_trashed(path: &Path) -> bool {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Check for "Trashed at" field — its presence implies trashed
|
||||
if extract_trashed_at_string(&parsed.data).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for "Trashed: true"
|
||||
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
|
||||
if let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
{
|
||||
return match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true")
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Delete multiple note files from disk.
|
||||
/// Returns the list of successfully deleted paths.
|
||||
/// Skips files that don't exist or fail to delete (logs warnings).
|
||||
@@ -181,165 +23,23 @@ pub fn batch_delete_notes(paths: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut deleted = Vec::new();
|
||||
for path in paths {
|
||||
let file = Path::new(path.as_str());
|
||||
match try_purge_file(file) {
|
||||
Some(p) => deleted.push(p),
|
||||
None if !file.exists() => {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
if !file.exists() {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
continue;
|
||||
}
|
||||
match fs::remove_file(file) {
|
||||
Ok(()) => {
|
||||
log::info!("Permanently deleted note: {}", path);
|
||||
deleted.push(path.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete {}: {}", path, e);
|
||||
}
|
||||
None => {} // try_purge_file already logged the warning
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete ALL trashed notes
|
||||
/// (regardless of age). Returns the list of deleted file paths.
|
||||
pub fn empty_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let mut deleted = Vec::new();
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
.filter(|e| is_file_trashed(e.path()))
|
||||
{
|
||||
if let Some(p) = try_purge_file(entry.path()) {
|
||||
deleted.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `_trashed_at` frontmatter is more than 30 days ago.
|
||||
///
|
||||
/// Safety checks enforced per file (all must pass):
|
||||
/// 1. `_trashed: true` present in frontmatter
|
||||
/// 2. `_trashed_at` present and parseable as a date
|
||||
/// 3. Date is strictly more than 30 days ago
|
||||
/// 4. File exists on disk
|
||||
/// 5. File path is inside the vault root
|
||||
///
|
||||
/// When `dry_run` is true, no files are deleted — only the list of candidates is returned.
|
||||
/// Returns the list of purged (or would-be-purged) file paths.
|
||||
pub fn purge_old_trash(vault_path: &Path, dry_run: bool) -> Result<Vec<PathBuf>, String> {
|
||||
if !vault_path.exists() || !vault_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let matter = Matter::<YAML>::new();
|
||||
let max_age_days = 30;
|
||||
let mut checked = 0usize;
|
||||
let mut purged: Vec<String> = Vec::new();
|
||||
let mut purged_paths: Vec<PathBuf> = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
// Safety check 4: file exists
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safety check 5: file is inside vault root
|
||||
if !is_inside_vault(path, vault_path) {
|
||||
log::warn!("Skipping file outside vault: {}", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Safety check 1: _trashed: true
|
||||
if !is_trashed_flag_set(&parsed.data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safety check 2: _trashed_at present and parseable
|
||||
let date_str = match extract_trashed_at_string(&parsed.data) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
log::warn!(
|
||||
"Trashed file missing _trashed_at, skipping: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let trashed_date = match parse_trashed_date(&date_str) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
log::warn!(
|
||||
"Unparseable _trashed_at '{}', skipping: {}",
|
||||
date_str,
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Safety check 3: strictly more than 30 days old
|
||||
let age = today.signed_duration_since(trashed_date);
|
||||
if age.num_days() <= max_age_days {
|
||||
continue;
|
||||
}
|
||||
|
||||
checked += 1;
|
||||
|
||||
if dry_run {
|
||||
log::info!(
|
||||
"[DRY-RUN] Would purge: {} (trashed {} days ago)",
|
||||
path.display(),
|
||||
age.num_days()
|
||||
);
|
||||
purged.push(path.to_string_lossy().to_string());
|
||||
purged_paths.push(path.to_path_buf());
|
||||
} else if let Some(p) = try_purge_file(path) {
|
||||
purged.push(p);
|
||||
purged_paths.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
write_purge_log(vault_path, checked, &purged, dry_run);
|
||||
|
||||
Ok(purged_paths)
|
||||
}
|
||||
|
||||
/// Legacy wrapper that calls purge_old_trash with dry_run=false and returns string paths.
|
||||
/// Used by the Tauri command and startup tasks.
|
||||
pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
purge_old_trash(vault, false)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -377,285 +77,6 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
fn old_date(days_ago: i64) -> String {
|
||||
(chrono::Utc::now().date_naive() - chrono::Duration::days(days_ago))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn trashed_content(date: &str) -> String {
|
||||
format!(
|
||||
"---\n_trashed: true\n_trashed_at: \"{}\"\n---\n# Trashed\n",
|
||||
date
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_deletes_old_trashed_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
create_test_file(dir.path(), "recent.md", &trashed_content(&old_date(5)));
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("old.md").exists());
|
||||
assert!(dir.path().join("recent.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_supports_datetime_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"dt.md",
|
||||
"---\n_trashed: true\n_trashed_at: \"2025-01-01T10:30:00Z\"\n---\n# DT\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_nonexistent_path() {
|
||||
let result = purge_old_trash(Path::new("/nonexistent/path"), false);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_exactly_30_days_not_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "borderline.md", &trashed_content(&old_date(30)));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("borderline.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_31_days_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "expired.md", &trashed_content(&old_date(31)));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("expired.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_nested_directories() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"sub/deep/old.md",
|
||||
&trashed_content("2025-01-01"),
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_dry_run_does_not_delete() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), true).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(
|
||||
dir.path().join("old.md").exists(),
|
||||
"dry-run must not delete files"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_missing_trashed_flag_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Has _trashed_at but no _trashed: true
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"no-flag.md",
|
||||
"---\n_trashed_at: \"2025-01-01\"\n---\n# No flag\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("no-flag.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_missing_trashed_at_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Has _trashed: true but no _trashed_at
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"no-date.md",
|
||||
"---\n_trashed: true\n---\n# No date\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("no-date.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_unparseable_date_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"bad-date.md",
|
||||
"---\n_trashed: true\n_trashed_at: \"not-a-date\"\n---\n# Bad date\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("bad-date.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_trashed_false_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"not-trashed.md",
|
||||
"---\n_trashed: false\n_trashed_at: \"2025-01-01\"\n---\n# Not trashed\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("not-trashed.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_legacy_field_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"legacy.md",
|
||||
"---\nTrashed: true\nTrashed at: \"2025-01-01\"\n---\n# Legacy\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("legacy.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_writes_purge_log() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let _ = purge_old_trash(dir.path(), false).unwrap();
|
||||
let log_path = dir.path().join(".laputa/purge.log");
|
||||
assert!(log_path.exists(), "purge.log must be created");
|
||||
let log_content = fs::read_to_string(&log_path).unwrap();
|
||||
assert!(log_content.contains("purged=1"));
|
||||
assert!(log_content.contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_wrapper_returns_strings() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed at: \"2026-01-01\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_yes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "trashed.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_normal_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
assert!(!is_file_trashed(&dir.path().join("normal.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_archived_not_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"archived.md",
|
||||
"---\nArchived: true\n---\n# Archived\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("archived.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_nonexistent_file() {
|
||||
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"active.md",
|
||||
"---\nTrashed: false\n---\n# Active\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("active.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_removes_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -687,49 +108,4 @@ mod tests {
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(!dir.path().join("exists.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_deletes_all_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Recently trashed — should be deleted
|
||||
let recent = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"recent.md",
|
||||
&format!(
|
||||
"---\nTrashed: true\nTrashed at: \"{}\"\n---\n# Recent\n",
|
||||
recent
|
||||
),
|
||||
);
|
||||
// Old trashed — should be deleted
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"old.md",
|
||||
"---\nTrashed: true\nTrashed at: \"2025-01-01\"\n---\n# Old\n",
|
||||
);
|
||||
// Not trashed — should be kept
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!dir.path().join("recent.md").exists());
|
||||
assert!(!dir.path().join("old.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_nonexistent_path() {
|
||||
let result = empty_trash("/nonexistent/path/that/does/not/exist");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,6 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||
// Boolean fields
|
||||
match field {
|
||||
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
|
||||
"trashed" => return evaluate_bool_field(entry.trashed, &cond.op, &cond.value),
|
||||
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -51,7 +51,7 @@ import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, InboxPeriod } from './types'
|
||||
import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
@@ -70,7 +70,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
@@ -83,6 +83,7 @@ function App() {
|
||||
setNoteListFilter('open')
|
||||
}, [])
|
||||
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
|
||||
@@ -338,8 +339,6 @@ function App() {
|
||||
})
|
||||
|
||||
const deleteActions = useDeleteActions({
|
||||
vaultPath: resolvedPath,
|
||||
entries: vault.entries,
|
||||
onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() },
|
||||
removeEntry: vault.removeEntry,
|
||||
setToastMessage,
|
||||
@@ -455,6 +454,7 @@ function App() {
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
visibleNotesRef,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection,
|
||||
@@ -465,7 +465,7 @@ function App() {
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: appSave.handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
@@ -493,8 +493,6 @@ function App() {
|
||||
onInstallMcp: installMcp,
|
||||
claudeCodeStatus: claudeCodeStatus ?? undefined,
|
||||
claudeCodeVersion: claudeCodeVersion ?? undefined,
|
||||
onEmptyTrash: deleteActions.handleEmptyTrash,
|
||||
trashedCount: deleteActions.trashedCount,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
@@ -587,7 +585,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -622,8 +620,6 @@ function App() {
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={entryActions.handleToggleOrganized}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onDeleteNote={deleteActions.handleDeleteNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
|
||||
@@ -31,8 +31,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
|
||||
@@ -12,11 +12,7 @@ const baseEntry: VaultEntry = {
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
@@ -26,6 +22,18 @@ const baseEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
sort: null,
|
||||
sidebarLabel: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
properties: {},
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
hasH1: false,
|
||||
}
|
||||
|
||||
const archivedEntry: VaultEntry = {
|
||||
@@ -33,12 +41,6 @@ const archivedEntry: VaultEntry = {
|
||||
archived: true,
|
||||
}
|
||||
|
||||
const trashedEntry: VaultEntry = {
|
||||
...baseEntry,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 5,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
showDiffToggle: false,
|
||||
@@ -55,31 +57,17 @@ describe('BreadcrumbBar — drag region', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — trash/restore', () => {
|
||||
it('shows trash button for non-trashed note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
|
||||
expect(screen.getByTitle('Move to trash (Cmd+Delete)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Restore from trash')).not.toBeInTheDocument()
|
||||
describe('BreadcrumbBar — delete', () => {
|
||||
it('shows delete button', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={vi.fn()} />)
|
||||
expect(screen.getByTitle('Delete (Cmd+Delete)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows restore button for trashed note', () => {
|
||||
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
|
||||
expect(screen.getByTitle('Restore from trash')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Move to trash (Cmd+Delete)')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onTrash when trash button is clicked', () => {
|
||||
const onTrash = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={onTrash} onRestore={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Move to trash (Cmd+Delete)'))
|
||||
expect(onTrash).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onRestore when restore button is clicked', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={onRestore} />)
|
||||
fireEvent.click(screen.getByTitle('Restore from trash'))
|
||||
expect(onRestore).toHaveBeenCalledOnce()
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByTitle('Delete (Cmd+Delete)'))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,13 +104,14 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('›')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows emoji icon when entry has an emoji icon', () => {
|
||||
it('does not render emoji icon in breadcrumb (icon removed from breadcrumb title)', () => {
|
||||
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
|
||||
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
|
||||
expect(screen.getByText('🚀')).toBeInTheDocument()
|
||||
// BreadcrumbTitle now only shows type label and filename stem — no icon
|
||||
expect(screen.queryByText('🚀')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show icon when entry has a non-emoji icon', () => {
|
||||
@@ -174,4 +163,17 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
fireEvent.click(screen.getByTitle('Raw editor'))
|
||||
expect(onToggleRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides raw toggle when forceRawMode is true (non-markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} forceRawMode={true} />)
|
||||
expect(screen.queryByTitle('Raw editor')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Back to editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows raw toggle when forceRawMode is false (markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} forceRawMode={false} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SlidersHorizontal,
|
||||
DotsThree,
|
||||
Trash,
|
||||
ArrowCounterClockwise,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
Star,
|
||||
@@ -26,14 +25,15 @@ interface BreadcrumbBarProps {
|
||||
onToggleDiff: () => void
|
||||
rawMode?: boolean
|
||||
onToggleRaw?: () => void
|
||||
/** When true, raw mode is forced (non-markdown file) — hide the toggle. */
|
||||
forceRawMode?: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: () => void
|
||||
onToggleOrganized?: () => void
|
||||
onTrash?: () => void
|
||||
onRestore?: () => void
|
||||
onDelete?: () => void
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
@@ -58,9 +58,9 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
|
||||
}
|
||||
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw,
|
||||
rawMode, onToggleRaw, forceRawMode,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onToggleFavorite, onToggleOrganized, onTrash, onRestore, onArchive, onUnarchive,
|
||||
onToggleFavorite, onToggleOrganized, onDelete, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
@@ -112,7 +112,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
)}
|
||||
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
@@ -149,23 +149,13 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
)}
|
||||
{entry.trashed ? (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onRestore}
|
||||
title="Restore from trash"
|
||||
>
|
||||
<ArrowCounterClockwise size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
|
||||
onClick={onTrash}
|
||||
title="Move to trash (Cmd+Delete)"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="Delete (Cmd+Delete)"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
{inspectorCollapsed && (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
@@ -189,14 +179,12 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
|
||||
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
const typeLabel = entry.isA ?? 'Note'
|
||||
const icon = entry.icon
|
||||
const emojiIcon = icon && /^\p{Emoji}/u.test(icon) ? icon : null
|
||||
const filenameStem = entry.filename.replace(/\.md$/, '')
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span className="shrink-0 text-border">›</span>
|
||||
{emojiIcon && <span className="shrink-0">{emojiIcon}</span>}
|
||||
<span className="truncate font-medium text-foreground">{entry.title}</span>
|
||||
<span className="truncate font-medium text-foreground">{filenameStem}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -204,10 +192,14 @@ function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, barRef, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
|
||||
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
|
||||
const titleAlwaysVisible = actionProps.rawMode || actionProps.diffMode
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
|
||||
className="breadcrumb-bar flex shrink-0 items-center"
|
||||
style={{
|
||||
height: 52,
|
||||
|
||||
@@ -6,41 +6,14 @@ describe('BulkActionBar', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
onArchive: vi.fn(),
|
||||
onTrash: vi.fn(),
|
||||
onRestore: vi.fn(),
|
||||
onDeletePermanently: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
isTrashView: false,
|
||||
}
|
||||
|
||||
it('shows Archive and Trash buttons in normal view', () => {
|
||||
it('shows Archive and Delete buttons in normal view', () => {
|
||||
render(<BulkActionBar {...defaultProps} />)
|
||||
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
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-trash-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRestore when Restore button clicked in trash view', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} onRestore={onRestore} />)
|
||||
fireEvent.click(screen.getByTestId('bulk-restore-btn'))
|
||||
expect(onRestore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onDeletePermanently when Delete button clicked in trash view', () => {
|
||||
const onDeletePermanently = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} onDeletePermanently={onDeletePermanently} />)
|
||||
fireEvent.click(screen.getByTestId('bulk-delete-btn'))
|
||||
expect(onDeletePermanently).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows selected count', () => {
|
||||
@@ -48,13 +21,11 @@ describe('BulkActionBar', () => {
|
||||
expect(screen.getByText('5 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Unarchive and Trash buttons in archived view', () => {
|
||||
it('shows Unarchive and Delete 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.getByTestId('bulk-delete-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', () => {
|
||||
|
||||
@@ -3,12 +3,9 @@ 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
|
||||
onDelete: () => void
|
||||
onUnarchive?: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
@@ -16,49 +13,33 @@ interface BulkActionBarProps {
|
||||
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 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) {
|
||||
function renderArchivedActions(onUnarchive: (() => void) | undefined, onDelete: () => 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 className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultActions(onArchive: () => void, onTrash: () => void) {
|
||||
function renderDefaultActions(onArchive: () => void, onDelete: () => 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 className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, isTrashView, isArchivedView, onArchive, onTrash, onRestore, onDeletePermanently, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
@@ -74,9 +55,8 @@ function BulkActionBarInner({ count, isTrashView, isArchivedView, onArchive, onT
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isTrashView ? renderTrashActions(onRestore, onArchive, onDeletePermanently)
|
||||
: isArchivedView ? renderArchivedActions(onUnarchive, onTrash)
|
||||
: renderDefaultActions(onArchive, onTrash)}
|
||||
{isArchivedView ? renderArchivedActions(onUnarchive, onDelete)
|
||||
: renderDefaultActions(onArchive, onDelete)}
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
|
||||
@@ -28,8 +28,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
@@ -851,17 +849,15 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
describe('system property filtering', () => {
|
||||
it('hides trashed, trashed_at, archived, archived_at, icon from properties panel', () => {
|
||||
it('hides 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' }}
|
||||
frontmatter={{ 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()
|
||||
@@ -874,11 +870,10 @@ describe('DynamicPropertiesPanel', () => {
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Trashed: true, Archived: false, Icon: '🎯', cadence: 'Daily' }}
|
||||
frontmatter={{ 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()
|
||||
|
||||
@@ -71,14 +71,14 @@ const SUGGESTED_PROPERTIES = ['Status', 'Date', 'URL'] as const
|
||||
function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
|
||||
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
|
||||
tabIndex={0}
|
||||
onClick={onAdd}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdd() } }}
|
||||
data-testid="suggested-property"
|
||||
>
|
||||
<span className="min-w-0 truncate text-[12px] text-muted-foreground/50">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-[12px] text-muted-foreground/30">{'\u2014'}</span>
|
||||
<span className="min-w-0 truncate text-[12px] text-muted-foreground/30">{'\u2014'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -361,12 +361,13 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Hide the first H1 heading in BlockNote — title is shown in TitleField above */
|
||||
.editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
|
||||
/* When TitleField is shown (no H1 in body), hide the first H1 heading in
|
||||
BlockNote to avoid duplicate title display. When hasH1 is set, the
|
||||
TitleField is hidden and the H1 in the editor serves as the title. */
|
||||
.title-section:not([data-has-h1]) ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
|
||||
display: none;
|
||||
}
|
||||
/* Also hide the BlockContainer wrapper if its heading is hidden */
|
||||
.editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
|
||||
.title-section:not([data-has-h1]) ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ const mockEntry: VaultEntry = {
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
@@ -76,9 +72,18 @@ const mockEntry: VaultEntry = {
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
sidebarLabel: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
properties: {},
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
hasH1: false,
|
||||
}
|
||||
|
||||
const mockContent = `---
|
||||
@@ -236,71 +241,6 @@ describe('Editor', () => {
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
mockEditor.insertBlocks.mockClear()
|
||||
})
|
||||
describe('trashed note behavior', () => {
|
||||
const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
const trashedTab = { entry: trashedEntry, content: mockContent }
|
||||
|
||||
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
|
||||
return render(<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
}
|
||||
|
||||
it('shows banner and read-only editor when note is trashed', () => {
|
||||
renderTrashed()
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
|
||||
})
|
||||
|
||||
it('does not show banner and sets editable for normal notes', () => {
|
||||
render(<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
})
|
||||
|
||||
it('calls onRestoreNote when banner restore is clicked', () => {
|
||||
const onRestoreNote = vi.fn()
|
||||
renderTrashed({ onRestoreNote })
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
|
||||
expect(onRestoreNote).toHaveBeenCalledWith(trashedEntry.path)
|
||||
})
|
||||
|
||||
it('calls onDeleteNote when banner delete is clicked', () => {
|
||||
const onDeleteNote = vi.fn()
|
||||
renderTrashed({ onDeleteNote })
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
|
||||
expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path)
|
||||
})
|
||||
|
||||
it('shows trash banner immediately when entry changes to trashed (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
const updatedTab = { entry: trashedEntryUpdated, content: mockContent }
|
||||
rerender(
|
||||
<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')
|
||||
})
|
||||
|
||||
it('removes trash banner immediately when entry is restored (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
|
||||
const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null }
|
||||
const restoredTab = { entry: restoredEntry, content: mockContent }
|
||||
rerender(
|
||||
<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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('click empty editor space', () => {
|
||||
@@ -343,27 +283,6 @@ describe('click empty editor space', () => {
|
||||
container.removeChild(editableDiv)
|
||||
})
|
||||
|
||||
it('does not focus editor when note is not editable (trashed)', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
|
||||
const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
entries={[trashedEntry]}
|
||||
tabs={[{ entry: trashedEntry, content: mockContent }]}
|
||||
activeTabPath={trashedEntry.path}
|
||||
/>
|
||||
)
|
||||
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
expect(container).toBeTruthy()
|
||||
fireEvent.click(container!)
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
expect(mockEditor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('archived note behavior', () => {
|
||||
|
||||
@@ -49,8 +49,6 @@ interface EditorProps {
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
@@ -208,7 +206,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onTitleSync,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
@@ -256,8 +254,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onTrashNote={onTrashNote}
|
||||
onRestoreNote={onRestoreNote}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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'
|
||||
@@ -43,8 +42,6 @@ interface EditorContentProps {
|
||||
onEditorChange?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
@@ -127,7 +124,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
|
||||
activeTab: Tab
|
||||
barRef: React.RefObject<HTMLDivElement | null>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'> & { forceRawMode?: boolean }
|
||||
}) {
|
||||
const wordCount = countWords(activeTab.content)
|
||||
const path = activeTab.entry.path
|
||||
@@ -142,14 +139,14 @@ function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
|
||||
onToggleDiff={props.onToggleDiff}
|
||||
rawMode={props.rawMode}
|
||||
onToggleRaw={props.onToggleRaw}
|
||||
forceRawMode={props.forceRawMode}
|
||||
showAIChat={props.showAIChat}
|
||||
onToggleAIChat={props.onToggleAIChat}
|
||||
inspectorCollapsed={props.inspectorCollapsed}
|
||||
onToggleInspector={props.onToggleInspector}
|
||||
onToggleFavorite={bindPath(props.onToggleFavorite, path)}
|
||||
onToggleOrganized={bindPath(props.onToggleOrganized, path)}
|
||||
onTrash={bindPath(props.onTrashNote, path)}
|
||||
onRestore={bindPath(props.onRestoreNote, path)}
|
||||
onDelete={bindPath(props.onDeleteNote, path)}
|
||||
onArchive={bindPath(props.onArchiveNote, path)}
|
||||
onUnarchive={bindPath(props.onUnarchiveNote, path)}
|
||||
/>
|
||||
@@ -161,17 +158,15 @@ export function EditorContent({
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath,
|
||||
onDeleteNote, rawLatestContentRef, onTitleChange,
|
||||
rawLatestContentRef, onTitleChange,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
||||
// so the banner appears regardless of navigation context.
|
||||
const { cssVars } = useEditorTheme()
|
||||
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
|
||||
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
|
||||
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
|
||||
const hasH1 = freshEntry?.hasH1 ?? activeTab?.entry.hasH1 ?? false
|
||||
// Non-markdown text files always use the raw editor (no BlockNote)
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
@@ -182,19 +177,16 @@ export function EditorContent({
|
||||
const titleSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// When in normal editor mode: use IntersectionObserver to show/hide the breadcrumb title
|
||||
// based on whether the title section has scrolled out of view.
|
||||
// In raw/diff mode, BreadcrumbBar handles title visibility via its rawMode/diffMode props.
|
||||
useEffect(() => {
|
||||
if (!showEditor) return
|
||||
|
||||
const bar = breadcrumbBarRef.current
|
||||
if (!bar) return
|
||||
|
||||
// In raw/diff mode the title section is not rendered, so there is nothing
|
||||
// for the IntersectionObserver to watch. Force the title visible instead.
|
||||
if (!showEditor) {
|
||||
bar.setAttribute('data-title-hidden', '')
|
||||
return () => { bar.removeAttribute('data-title-hidden') }
|
||||
}
|
||||
|
||||
const el = titleSectionRef.current
|
||||
if (!el) return
|
||||
if (!bar || !el) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([e]) => {
|
||||
if (e.isIntersecting) bar.removeAttribute('data-title-hidden')
|
||||
@@ -225,14 +217,8 @@ export function EditorContent({
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
barRef={breadcrumbBarRef}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, forceRawMode: isNonMarkdownText, ...breadcrumbProps }}
|
||||
/>
|
||||
{isTrashed && (
|
||||
<TrashedNoteBanner
|
||||
onRestore={() => breadcrumbProps.onRestoreNote?.(path)}
|
||||
onDeletePermanently={() => onDeleteNote?.(path)}
|
||||
/>
|
||||
)}
|
||||
{isArchived && breadcrumbProps.onUnarchiveNote && (
|
||||
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(path)} />
|
||||
)}
|
||||
@@ -247,28 +233,32 @@ export function EditorContent({
|
||||
{showEditor && (
|
||||
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
|
||||
<div className="editor-content-wrapper">
|
||||
<div ref={titleSectionRef} className="title-section">
|
||||
{!emojiIcon && (
|
||||
<div className="title-section__add-icon">
|
||||
<NoteIcon icon={null} editable={!isTrashed} onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
</div>
|
||||
<div ref={titleSectionRef} className="title-section" data-has-h1={hasH1 || undefined}>
|
||||
{!hasH1 && (
|
||||
<>
|
||||
{!emojiIcon && (
|
||||
<div className="title-section__add-icon">
|
||||
<NoteIcon icon={null} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{emojiIcon && (
|
||||
<NoteIcon icon={emojiIcon} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
)}
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable
|
||||
notePath={path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
<div className="title-section__separator" />
|
||||
</>
|
||||
)}
|
||||
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{emojiIcon && (
|
||||
<NoteIcon icon={emojiIcon} editable={!isTrashed} onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
|
||||
)}
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable={!isTrashed}
|
||||
notePath={path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
<div className="title-section__separator" />
|
||||
</div>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,8 +15,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
@@ -35,7 +33,6 @@ const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI Research', isA: 'Topic' }),
|
||||
makeEntry({ path: '/vault/note/plain.md', filename: 'plain.md', title: 'Plain Note', isA: null }),
|
||||
makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice Smith', isA: 'Person', aliases: ['Alice'] }),
|
||||
makeEntry({ path: '/vault/trashed.md', filename: 'trashed.md', title: 'Trashed Note', isA: null, trashed: true }),
|
||||
]
|
||||
|
||||
describe('FilterBuilder wikilink autocomplete', () => {
|
||||
@@ -91,7 +88,7 @@ describe('FilterBuilder wikilink autocomplete', () => {
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inserts [[note-title]] when a note is selected', () => {
|
||||
it('inserts [[stem|title]] when a note is selected', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha' }],
|
||||
})
|
||||
@@ -100,7 +97,7 @@ describe('FilterBuilder wikilink autocomplete', () => {
|
||||
fireEvent.click(screen.getByText('Alpha Project'))
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha Project]]' }],
|
||||
all: [{ field: 'title', op: 'contains', value: '[[alpha|Alpha Project]]' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -129,15 +126,6 @@ describe('FilterBuilder wikilink autocomplete', () => {
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes trashed notes from autocomplete', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Trashed' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.queryByText('Trashed Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches on aliases', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alice' }],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, X, CalendarBlank } from '@phosphor-icons/react'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -111,8 +112,10 @@ function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>
|
||||
const isA = e.isA
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const noteType = isA || undefined
|
||||
const stem = e.filename.replace(/\.md$/, '')
|
||||
return {
|
||||
title: e.title,
|
||||
stem,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
|
||||
@@ -124,7 +127,7 @@ function matchWikilinkEntries(entries: VaultEntry[], typeEntryMap: Record<string
|
||||
if (query.length < MIN_WIKILINK_QUERY) return []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return entries
|
||||
.filter(e => !e.trashed && entryMatchesQuery(e, lowerQuery))
|
||||
.filter(e => entryMatchesQuery(e, lowerQuery))
|
||||
.slice(0, MAX_WIKILINK_RESULTS)
|
||||
.map(e => toWikilinkMatch(e, typeEntryMap))
|
||||
}
|
||||
@@ -146,18 +149,30 @@ function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: (
|
||||
}, [refs, onClose])
|
||||
}
|
||||
|
||||
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef }: {
|
||||
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef, anchorRef }: {
|
||||
matches: WikilinkMatch[]
|
||||
selectedIndex: number
|
||||
onSelect: (title: string) => void
|
||||
onSelect: (title: string, stem?: string) => void
|
||||
onHover: (index: number) => void
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
}) {
|
||||
return (
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = anchorRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
setPos({ top: rect.bottom + 2, left: rect.left, width: rect.width })
|
||||
}, [anchorRef, matches])
|
||||
|
||||
if (!pos) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="wikilink-menu"
|
||||
className="wikilink-menu wikilink-menu--filter"
|
||||
ref={menuRef}
|
||||
style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, zIndex: 50 }}
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width }}
|
||||
data-testid="wikilink-dropdown"
|
||||
>
|
||||
{matches.map((item, index) => (
|
||||
@@ -165,21 +180,22 @@ function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef }
|
||||
key={item.title}
|
||||
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onSelect(item.title)}
|
||||
onClick={() => onSelect(item.title, item.stem)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
>
|
||||
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
{item.TypeIcon && <item.TypeIcon width={12} height={12} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 6px' }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,7 +219,7 @@ function useScrollSelectedIntoView(menuRef: React.RefObject<HTMLDivElement | nul
|
||||
function useDropdownKeyboard(
|
||||
matches: WikilinkMatch[],
|
||||
open: boolean,
|
||||
onSelect: (title: string) => void,
|
||||
onSelect: (title: string, stem?: string) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
@@ -220,7 +236,8 @@ function useDropdownKeyboard(
|
||||
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault()
|
||||
onSelect(matches[selectedIndex].title)
|
||||
const m = matches[selectedIndex]
|
||||
onSelect(m.title, m.stem)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
@@ -241,8 +258,9 @@ function WikilinkValueInput({ value, entries, onChange }: {
|
||||
|
||||
const matches = useWikilinkMatches(entries, value, open)
|
||||
|
||||
const handleSelect = useCallback((title: string) => {
|
||||
onChange(`[[${title}]]`)
|
||||
const handleSelect = useCallback((title: string, stem?: string) => {
|
||||
const wikilink = stem && stem !== title ? `[[${stem}|${title}]]` : `[[${title}]]`
|
||||
onChange(wikilink)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
@@ -261,7 +279,7 @@ function WikilinkValueInput({ value, entries, onChange }: {
|
||||
}, [onChange, resetIndex])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }} className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-8 w-full text-sm"
|
||||
@@ -279,6 +297,7 @@ function WikilinkValueInput({ value, entries, onChange }: {
|
||||
onSelect={handleSelect}
|
||||
onHover={setSelectedIndex}
|
||||
menuRef={menuRef}
|
||||
anchorRef={inputRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,8 +15,6 @@ const mockEntry: VaultEntry = {
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
@@ -60,8 +58,6 @@ const referrerEntry: VaultEntry = {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -345,8 +341,6 @@ This is a test note with some words to count.
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 500,
|
||||
@@ -372,8 +366,6 @@ This is a test note with some words to count.
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
@@ -399,8 +391,6 @@ This is a test note with some words to count.
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 400,
|
||||
@@ -426,8 +416,6 @@ This is a test note with some words to count.
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -569,8 +557,6 @@ Status: Active
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
|
||||
@@ -19,8 +19,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
@@ -180,7 +178,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
fireEvent.click(screen.getByText('+ Add relationship'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
fireEvent.click(screen.getByTestId('submit-add-relationship'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Related to', '[[AI]]')
|
||||
})
|
||||
|
||||
@@ -261,21 +259,6 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(screen.getByTitle('Archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trashed indicator for trashed entries', () => {
|
||||
const trashedEntry = makeEntry({
|
||||
path: '/vault/project/trash.md', filename: 'trash.md', title: 'Trash Project', isA: 'Project', trashed: true,
|
||||
})
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/trash]]'] }}
|
||||
entries={[trashedEntry]}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTitle('Trashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles aliased wikilinks [[path|Display]]', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
@@ -774,13 +757,6 @@ describe('ReferencedByPanel', () => {
|
||||
expect(screen.getByTitle('Archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trashed indicator for trashed entries', () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/a.md', title: 'Trash Note', trashed: true }), viaKey: 'Has' },
|
||||
]
|
||||
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
|
||||
expect(screen.getByTitle('Trashed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GitHistoryPanel', () => {
|
||||
@@ -873,18 +849,6 @@ describe('InstancesPanel', () => {
|
||||
expect(buttons[2].textContent).toContain('Q1 2026')
|
||||
})
|
||||
|
||||
it('excludes trashed instances', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 2000 }),
|
||||
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 Trashed', isA: 'Quarter', trashed: true, modifiedAt: 3000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByText('Q1 2026')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Q2 Trashed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dims archived instances', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/old.md', title: 'Q4 2024', isA: 'Quarter', archived: true, modifiedAt: 1000 }),
|
||||
|
||||
@@ -15,8 +15,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
|
||||
@@ -9,7 +9,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/test.md', filename: 'test.md', title: 'Test Note',
|
||||
isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, archived: false, trashed: false, trashedAt: null,
|
||||
status: null, archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100,
|
||||
snippet: 'A snippet', wordCount: 50,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
|
||||
@@ -29,26 +29,6 @@ export function getTypeIcon(isA: string | null, customIcon?: string | null): Com
|
||||
return (isA && TYPE_ICON_MAP[isA]) || FileText
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_SECS = 86400 * 30
|
||||
|
||||
function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||
const { isExpired, suffix } = useMemo(() => {
|
||||
// eslint-disable-next-line react-hooks/purity -- Date.now() intentionally memoized on trashedAt
|
||||
const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0
|
||||
const expired = trashedAge >= THIRTY_DAYS_SECS
|
||||
return {
|
||||
isExpired: expired,
|
||||
suffix: expired ? ' — will be permanently deleted' : '',
|
||||
}
|
||||
}, [entry.trashedAt])
|
||||
const style = isExpired ? { color: 'var(--destructive)', fontWeight: 500 } as const : undefined
|
||||
return (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground" style={style}>
|
||||
Trashed {relativeDate(entry.trashedAt)}{suffix}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: string }> = {
|
||||
pendingSave: { color: 'var(--accent-green)', testId: 'pending-save-indicator', title: 'Saving to disk…' },
|
||||
new: { color: 'var(--accent-green)', testId: 'new-indicator', title: 'New (uncommitted)' },
|
||||
@@ -68,7 +48,7 @@ function StatusDot({ noteStatus }: { noteStatus: NoteStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StateBadge({ archived, trashed }: { archived: boolean; trashed: boolean }) {
|
||||
function StateBadge({ archived }: { archived: boolean }) {
|
||||
if (archived) {
|
||||
return (
|
||||
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
@@ -76,13 +56,6 @@ function StateBadge({ archived, trashed }: { archived: boolean; trashed: boolean
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (trashed) {
|
||||
return (
|
||||
<span className="ml-1.5 inline-block align-middle" style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
TRASHED
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -236,7 +209,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
{!isBinary && <StateBadge archived={entry.archived} trashed={entry.trashed} />}
|
||||
{!isBinary && <StateBadge archived={entry.archived} />}
|
||||
</div>
|
||||
</div>
|
||||
{entry.snippet && !isBinary && (
|
||||
@@ -247,9 +220,8 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
{!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && (
|
||||
<PropertyChips entry={entry} displayProps={te.listPropertiesDisplay} />
|
||||
)}
|
||||
{!isBinary && (entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
{!isBinary && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -25,8 +25,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
@@ -54,8 +52,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
@@ -84,8 +80,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
@@ -111,8 +105,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
@@ -138,8 +130,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
@@ -158,7 +148,7 @@ const mockEntries: VaultEntry[] = [
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
archived: false,
|
||||
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
@@ -685,95 +675,7 @@ describe('NoteList sort controls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- Trash feature tests ---
|
||||
|
||||
const trashedEntry: VaultEntry = {
|
||||
path: '/vault/note/old-draft.md',
|
||||
filename: 'old-draft.md',
|
||||
title: 'Old Draft Notes',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 5,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 280,
|
||||
snippet: 'Some draft content that is no longer needed.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const expiredTrashedEntry: VaultEntry = {
|
||||
path: '/vault/note/deprecated-api.md',
|
||||
filename: 'deprecated-api.md',
|
||||
title: 'Deprecated API Notes',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 35,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 190,
|
||||
snippet: 'Old API docs replaced by v2.',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry]
|
||||
|
||||
describe('filterEntries — trash', () => {
|
||||
it('excludes trashed entries from "all" filter', () => {
|
||||
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'all' })
|
||||
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
||||
expect(result.find((e) => e.title === 'Build Laputa App')).toBeDefined()
|
||||
})
|
||||
|
||||
it('excludes trashed entries from section group', () => {
|
||||
const result = filterEntries(entriesWithTrashed, { kind: 'sectionGroup', type: 'Note' })
|
||||
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trash filter returns only trashed entries', () => {
|
||||
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'trash' })
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.every((e) => e.trashed)).toBe(true)
|
||||
})
|
||||
|
||||
it('archived filter excludes trashed entries', () => {
|
||||
const archivedAndTrashed = [
|
||||
...mockEntries,
|
||||
{ ...mockEntries[0], path: '/archived.md', archived: true, trashed: false, trashedAt: null, title: 'Archived Note' },
|
||||
trashedEntry,
|
||||
]
|
||||
const result = filterEntries(archivedAndTrashed, { kind: 'filter', filter: 'archived' })
|
||||
expect(result.find((e) => e.title === 'Archived Note')).toBeDefined()
|
||||
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('filterEntries — entity', () => {
|
||||
it('entity filter returns empty (entity view uses relationship groups instead)', () => {
|
||||
const result = filterEntries(mockEntries, { kind: 'entity', entry: mockEntries[4] })
|
||||
expect(result).toHaveLength(0)
|
||||
@@ -828,39 +730,6 @@ describe('NoteList — status indicators', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList — trash view', () => {
|
||||
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
|
||||
|
||||
it('shows "Trash" header when trash filter is active', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only trashed entries in trash view', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows TRASHED badge on trashed entries', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
const badges = screen.getAllByText('TRASHED')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows 30-day warning banner when expired notes exist', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Trash is empty" when no trashed entries', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --- Virtual list performance tests ---
|
||||
|
||||
describe('NoteList — virtual list with large datasets', () => {
|
||||
@@ -1214,10 +1083,10 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it.each([
|
||||
{ label: 'bulk archive via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) },
|
||||
{ label: 'bulk trash via button', prop: 'onBulkTrash', trigger: () => fireEvent.click(screen.getByTestId('bulk-trash-btn')) },
|
||||
{ label: 'bulk delete via button', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.click(screen.getByTestId('bulk-delete-btn')) },
|
||||
{ label: 'Cmd+E archives', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) },
|
||||
{ label: 'Cmd+Backspace trashes', prop: 'onBulkTrash', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) },
|
||||
{ label: 'Cmd+Delete trashes', prop: 'onBulkTrash', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) },
|
||||
{ label: 'Cmd+Backspace deletes', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) },
|
||||
{ label: 'Cmd+Delete deletes', prop: 'onBulkDeletePermanently', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) },
|
||||
])('$label selected notes and clears selection', ({ prop, trigger }) => {
|
||||
const handler = vi.fn()
|
||||
selectTwoNotes({ [prop]: handler })
|
||||
@@ -1254,8 +1123,6 @@ const typeEntry: VaultEntry = {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -1335,28 +1202,19 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
})
|
||||
|
||||
describe('countByFilter', () => {
|
||||
it('counts open, archived, and trashed notes per type', () => {
|
||||
it('counts open and archived notes per type', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/3.md', isA: 'Project', trashed: true }),
|
||||
makeEntry({ path: '/4.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/5.md', isA: 'Note' }),
|
||||
makeEntry({ path: '/3.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/4.md', isA: 'Note' }),
|
||||
]
|
||||
const counts = countByFilter(entries, 'Project')
|
||||
expect(counts).toEqual({ open: 2, archived: 1, trashed: 1 })
|
||||
expect(counts).toEqual({ open: 2, archived: 1 })
|
||||
})
|
||||
|
||||
it('returns zeros when type has no entries', () => {
|
||||
expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0, trashed: 0 })
|
||||
})
|
||||
|
||||
it('counts trashed note that is also archived as trashed only', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Project', archived: true, trashed: true }),
|
||||
]
|
||||
const counts = countByFilter(entries, 'Project')
|
||||
expect(counts).toEqual({ open: 0, archived: 0, trashed: 1 })
|
||||
expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1365,7 +1223,6 @@ describe('NoteList — filter pills', () => {
|
||||
makeEntry({ path: '/p1.md', title: 'Open Project 1', isA: 'Project' }),
|
||||
makeEntry({ path: '/p2.md', title: 'Open Project 2', isA: 'Project' }),
|
||||
makeEntry({ path: '/p3.md', title: 'Archived Project', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/p4.md', title: 'Trashed Project', isA: 'Project', trashed: true, trashedAt: 1700000000 }),
|
||||
makeEntry({ path: '/n1.md', title: 'Some Note', isA: 'Note' }),
|
||||
]
|
||||
|
||||
@@ -1376,7 +1233,6 @@ describe('NoteList — filter pills', () => {
|
||||
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows filter pills in All Notes view', () => {
|
||||
@@ -1386,20 +1242,17 @@ describe('NoteList — filter pills', () => {
|
||||
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct All Notes count badges across all types', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// projectEntries: 2 open Projects + 1 open Note = 3 open, 1 archived, 1 trashed
|
||||
// projectEntries: 2 open Projects + 1 open Note = 3 open, 1 archived
|
||||
const openPill = screen.getByTestId('filter-pill-open')
|
||||
const archivedPill = screen.getByTestId('filter-pill-archived')
|
||||
const trashedPill = screen.getByTestId('filter-pill-trashed')
|
||||
expect(openPill).toHaveTextContent('3')
|
||||
expect(archivedPill).toHaveTextContent('1')
|
||||
expect(trashedPill).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('shows archived notes in All Notes when filter is archived', () => {
|
||||
@@ -1411,28 +1264,17 @@ describe('NoteList — filter pills', () => {
|
||||
expect(screen.queryByText('Some Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trashed notes in All Notes when filter is trashed', () => {
|
||||
render(
|
||||
<NoteList noteListFilter="trashed" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Trashed Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct count badges for each filter', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={projectEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Open pill should show 2, Archived 1, Trashed 1
|
||||
// Open pill should show 2, Archived 1
|
||||
const openPill = screen.getByTestId('filter-pill-open')
|
||||
const archivedPill = screen.getByTestId('filter-pill-archived')
|
||||
const trashedPill = screen.getByTestId('filter-pill-trashed')
|
||||
expect(openPill).toHaveTextContent('Open')
|
||||
expect(openPill).toHaveTextContent('2')
|
||||
expect(archivedPill).toHaveTextContent('Archived')
|
||||
expect(archivedPill).toHaveTextContent('1')
|
||||
expect(trashedPill).toHaveTextContent('Trashed')
|
||||
expect(trashedPill).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('calls onNoteListFilterChange when a pill is clicked', () => {
|
||||
@@ -1452,14 +1294,6 @@ describe('NoteList — filter pills', () => {
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trashed notes when filter is set to trashed', () => {
|
||||
render(
|
||||
<NoteList noteListFilter="trashed" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Trashed Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state for archived filter when no archived notes exist', () => {
|
||||
const entriesNoArchived = projectEntries.filter(e => !e.archived)
|
||||
render(
|
||||
@@ -1473,7 +1307,6 @@ describe('NoteList — filterEntries with subFilter', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/3.md', title: 'Trashed', isA: 'Project', trashed: true }),
|
||||
makeEntry({ path: '/4.md', title: 'Other', isA: 'Note' }),
|
||||
]
|
||||
|
||||
@@ -1487,11 +1320,6 @@ describe('NoteList — filterEntries with subFilter', () => {
|
||||
expect(result.map(e => e.title)).toEqual(['Archived'])
|
||||
})
|
||||
|
||||
it('filters sectionGroup by trashed sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' }, 'trashed')
|
||||
expect(result.map(e => e.title)).toEqual(['Trashed'])
|
||||
})
|
||||
|
||||
it('without sub-filter, defaults to active only', () => {
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
|
||||
expect(result.map(e => e.title)).toEqual(['Active'])
|
||||
@@ -1506,11 +1334,6 @@ describe('NoteList — filterEntries with subFilter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'archived')
|
||||
expect(result.map(e => e.title)).toEqual(['Archived'])
|
||||
})
|
||||
|
||||
it('filters all notes by trashed sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'trashed')
|
||||
expect(result.map(e => e.title)).toEqual(['Trashed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAllByFilter', () => {
|
||||
@@ -1519,11 +1342,9 @@ describe('countAllByFilter', () => {
|
||||
makeEntry({ path: '/1.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', isA: 'Note' }),
|
||||
makeEntry({ path: '/3.md', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', isA: 'Note', trashed: true }),
|
||||
makeEntry({ path: '/5.md', isA: 'Person', archived: true, trashed: true }),
|
||||
]
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 })
|
||||
expect(counts).toEqual({ open: 2, archived: 1 })
|
||||
})
|
||||
|
||||
it('excludes non-markdown files from counts', () => {
|
||||
@@ -1533,7 +1354,7 @@ describe('countAllByFilter', () => {
|
||||
makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }),
|
||||
]
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 1, archived: 0, trashed: 0 })
|
||||
expect(counts).toEqual({ open: 1, archived: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,20 +35,14 @@ function useViewFlags(selection: SidebarSelection) {
|
||||
function useBulkActions(
|
||||
multiSelect: ReturnType<typeof useMultiSelect>,
|
||||
onBulkArchive: NoteListProps['onBulkArchive'],
|
||||
onBulkTrash: NoteListProps['onBulkTrash'],
|
||||
onBulkRestore: NoteListProps['onBulkRestore'],
|
||||
onBulkDeletePermanently: NoteListProps['onBulkDeletePermanently'],
|
||||
isTrashView: boolean,
|
||||
isArchivedView: boolean,
|
||||
) {
|
||||
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 handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
|
||||
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : isArchivedView ? handleBulkUnarchive : handleBulkArchive
|
||||
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
|
||||
return { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete }
|
||||
const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
|
||||
const bulkArchiveOrUnarchive = isArchivedView ? handleBulkUnarchive : handleBulkArchive
|
||||
return { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive }
|
||||
}
|
||||
|
||||
function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { isChangesView: boolean; onDiscardFile?: (relativePath: string) => Promise<void>; modifiedFiles?: ModifiedFile[] }) {
|
||||
@@ -130,26 +124,24 @@ interface NoteListProps {
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onCreateNote: (type?: string) => void
|
||||
onBulkArchive?: (paths: string[]) => void
|
||||
onBulkTrash?: (paths: string[]) => void
|
||||
onBulkRestore?: (paths: string[]) => void
|
||||
onBulkDeletePermanently?: (paths: string[]) => void
|
||||
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
|
||||
onDiscardFile?: (relativePath: string) => Promise<void>
|
||||
onAutoTriggerDiff?: () => void
|
||||
views?: ViewFile[]
|
||||
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkDeletePermanently, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views, visibleNotesRef }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
|
||||
const subFilter = showFilterPills ? noteListFilter : undefined
|
||||
|
||||
const filterCounts = useMemo(
|
||||
() => isSectionGroup && selection.kind === 'sectionGroup' ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
|
||||
() => isSectionGroup && selection.kind === 'sectionGroup' ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0 },
|
||||
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
|
||||
)
|
||||
|
||||
@@ -168,7 +160,13 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
}
|
||||
return map
|
||||
}, [isChangesView, modifiedFiles])
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
|
||||
const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
|
||||
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
|
||||
if (visibleNotesRef) {
|
||||
visibleNotesRef.current = isEntityView
|
||||
? searchedGroups.flatMap((g) => g.entries)
|
||||
: searched
|
||||
}
|
||||
const deletedCount = useMemo(
|
||||
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
|
||||
[isChangesView, modifiedFiles],
|
||||
@@ -187,8 +185,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
}
|
||||
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
|
||||
|
||||
const { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete } = useBulkActions(multiSelect, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, isTrashView, isArchivedView)
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
|
||||
const { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView)
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently)
|
||||
|
||||
const { handleNoteContextMenu, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
|
||||
|
||||
@@ -215,20 +213,20 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} onUpdateTypeProperty={onUpdateTypeSort} />
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onUpdateTypeProperty={onUpdateTypeSort} />
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="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} isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
<ListView isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
)}
|
||||
</div>
|
||||
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onDelete={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
|
||||
)}
|
||||
{contextMenuNode}
|
||||
{dialogNode}
|
||||
|
||||
@@ -15,8 +15,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
|
||||
@@ -7,7 +7,7 @@ function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
path, filename: `${title}.md`, title, isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
cadence: null, archived: false,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, outgoingLinks: [],
|
||||
|
||||
@@ -58,7 +58,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
|
||||
@@ -27,8 +27,6 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: NOW - 7200,
|
||||
createdAt: NOW - 86400 * 30,
|
||||
fileSize: 500,
|
||||
@@ -54,8 +52,6 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: NOW - 86400 * 5,
|
||||
createdAt: NOW - 86400 * 5,
|
||||
fileSize: 300,
|
||||
@@ -211,7 +207,9 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const resultTwo = screen.getByText('Refactoring Retreat').closest('[class*="cursor-pointer"]')!
|
||||
@@ -243,7 +241,9 @@ describe('SearchPanel', () => {
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(onSelectNote).toHaveBeenCalledWith(MOCK_ENTRIES[0])
|
||||
await waitFor(() => {
|
||||
expect(onSelectNote).toHaveBeenCalledWith(MOCK_ENTRIES[0])
|
||||
})
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -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, archived: false, trashed: false, trashedAt: null,
|
||||
status: null, archived: false,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
@@ -100,15 +100,6 @@ describe('buildDynamicSections', () => {
|
||||
expect(projectSections).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('excludes trashed type definitions', () => {
|
||||
const entries: VaultEntry[] = []
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Deleted: { ...baseEntry, title: 'Deleted', isA: 'Type', trashed: true },
|
||||
}
|
||||
const sections = buildDynamicSections(entries, typeEntryMap)
|
||||
expect(sections.map((s) => s.type)).not.toContain('Deleted')
|
||||
})
|
||||
|
||||
it('excludes archived type definitions', () => {
|
||||
const entries: VaultEntry[] = []
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
|
||||
@@ -16,8 +16,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
@@ -44,8 +42,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
@@ -72,8 +68,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
@@ -100,8 +94,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: 'Weekly',
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 128,
|
||||
@@ -128,8 +120,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
@@ -156,8 +146,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 180,
|
||||
@@ -184,8 +172,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
@@ -212,8 +198,6 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -328,8 +312,6 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -356,8 +338,6 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -384,8 +364,6 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
@@ -411,8 +389,6 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 400,
|
||||
@@ -441,7 +417,7 @@ describe('Sidebar', () => {
|
||||
|
||||
it('shows section for type with zero active entries when type definition exists', () => {
|
||||
// Only Type definitions exist for Book, no actual Book instances
|
||||
// New behavior: types are shown in sidebar as long as the Type definition exists (not trashed/archived)
|
||||
// New behavior: types are shown in sidebar as long as the Type definition exists (not archived)
|
||||
const entriesNoBookInstance = entriesWithCustomTypes.filter((e) => !(e.isA === 'Book' && e.title !== 'Book'))
|
||||
render(<Sidebar entries={entriesNoBookInstance} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Books should still appear because the Book type definition exists
|
||||
@@ -450,21 +426,6 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides type section when all entries of that type are trashed', () => {
|
||||
const entriesWithTrashedOnly: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/event/cancelled.md', filename: 'cancelled.md', title: 'Cancelled Event',
|
||||
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no sections when entries list is empty', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Projects')).not.toBeInTheDocument()
|
||||
@@ -485,8 +446,6 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -513,7 +472,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
path: '/vault/news.md', filename: 'news.md', title: 'News', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
archived: false, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: 'News', outgoingLinks: [],
|
||||
properties: {},
|
||||
@@ -521,7 +480,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
archived: false, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
@@ -539,7 +498,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
path: '/vault/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
archived: false, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: 'Contacts', outgoingLinks: [],
|
||||
properties: {},
|
||||
@@ -570,8 +529,6 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -693,7 +650,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
path: '/vault/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
archived: false, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
@@ -701,7 +658,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
path: '/vault/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
archived: false, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
@@ -709,7 +666,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
path: '/vault/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
archived: false, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
@@ -802,7 +759,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
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,
|
||||
archived: false, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
@@ -810,7 +767,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
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,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 300, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
@@ -818,7 +775,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
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,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
@@ -841,7 +798,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
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,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 100, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
@@ -862,7 +819,7 @@ describe('Sidebar', () => {
|
||||
isA: 'Monday Ideas',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 310, snippet: '', wordCount: 120,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
@@ -876,7 +833,7 @@ describe('Sidebar', () => {
|
||||
isA: 'Monday Ideas',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 280, snippet: '', wordCount: 95,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
@@ -915,7 +872,7 @@ describe('Sidebar', () => {
|
||||
{
|
||||
path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build App',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 300, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: '🚀', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
@@ -929,7 +886,7 @@ describe('Sidebar', () => {
|
||||
const favEntry: VaultEntry = {
|
||||
path: '/vault/project/fav.md', filename: 'fav.md', title: 'My Favorite Note',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 100, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
@@ -947,12 +904,6 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides trashed favorites from the section', () => {
|
||||
const trashedFav = { ...favEntry, trashed: true }
|
||||
render(<Sidebar entries={[...mockEntries, trashedFav]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with favorites filter when clicking a favorite', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={onSelect} />)
|
||||
@@ -1036,4 +987,67 @@ describe('Sidebar', () => {
|
||||
expect(onCreateNewType).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('view note count chips', () => {
|
||||
const mockViews = [
|
||||
{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
},
|
||||
{
|
||||
filename: 'all-topics.yml',
|
||||
definition: {
|
||||
name: 'All Topics',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Topic' }] },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it('shows note count chip for each view matching the filter results', () => {
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />
|
||||
)
|
||||
// 'Active Projects' filters for type=Project -> mockEntries has 1 Project (build-app.md)
|
||||
const projectLabel = screen.getByText('Active Projects')
|
||||
const projectNavItem = projectLabel.closest('[class*="cursor-pointer"]')!
|
||||
// The count chip is a sibling span inside the NavItem
|
||||
const projectCount = projectNavItem.querySelector('span:last-child')
|
||||
expect(projectCount?.textContent).toBe('1')
|
||||
|
||||
// 'All Topics' filters for type=Topic -> mockEntries has 2 Topics
|
||||
const topicLabel = screen.getByText('All Topics')
|
||||
const topicNavItem = topicLabel.closest('[class*="cursor-pointer"]')!
|
||||
const topicCount = topicNavItem.querySelector('span:last-child')
|
||||
expect(topicCount?.textContent).toBe('2')
|
||||
})
|
||||
|
||||
it('does not show count chip for views with 0 matching notes', () => {
|
||||
const emptyView = [{
|
||||
filename: 'empty.yml',
|
||||
definition: {
|
||||
name: 'Empty View',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Nonexistent' }] },
|
||||
},
|
||||
}]
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={emptyView} />
|
||||
)
|
||||
expect(screen.getByText('Empty View')).toBeInTheDocument()
|
||||
// No count chip rendered for 0 results (NavItem hides count <= 0)
|
||||
const viewContainer = screen.getByText('Empty View').closest('div')
|
||||
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { evaluateView } from '../utils/viewFilters'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -100,13 +101,12 @@ function useSidebarCollapsed() {
|
||||
|
||||
function useEntryCounts(entries: VaultEntry[]) {
|
||||
return useMemo(() => {
|
||||
let active = 0, archived = 0, trashed = 0
|
||||
let active = 0, archived = 0
|
||||
for (const e of entries) {
|
||||
if (e.trashed) trashed++
|
||||
else if (e.archived) archived++
|
||||
if (e.archived) archived++
|
||||
else active++
|
||||
}
|
||||
return { activeCount: active, archivedCount: archived, trashedCount: trashed }
|
||||
return { activeCount: active, archivedCount: archived }
|
||||
}, [entries])
|
||||
}
|
||||
|
||||
@@ -170,19 +170,22 @@ function SidebarGroupHeader({ label, collapsed, onToggle, count, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView }: {
|
||||
function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView, entries }: {
|
||||
view: ViewFile
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
onEditView?: (filename: string) => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
entries: VaultEntry[]
|
||||
}) {
|
||||
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
|
||||
return (
|
||||
<div className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={view.definition.icon}
|
||||
label={view.definition.name}
|
||||
count={count}
|
||||
isActive={isActive}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
@@ -210,7 +213,7 @@ function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView }: {
|
||||
function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView, entries }: {
|
||||
views: ViewFile[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
@@ -219,6 +222,7 @@ function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreat
|
||||
onCreateView?: () => void
|
||||
onEditView?: (filename: string) => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
entries: VaultEntry[]
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border" style={{ padding: '0 6px' }}>
|
||||
@@ -241,6 +245,7 @@ function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreat
|
||||
onSelect={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
onEditView={onEditView}
|
||||
onDeleteView={onDeleteView}
|
||||
entries={entries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -314,7 +319,7 @@ function SortableSection({ group, sectionProps }: {
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const itemCount = sectionProps.entries.filter((e) =>
|
||||
!e.archived && !e.trashed && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type),
|
||||
!e.archived && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type),
|
||||
).length
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
@@ -364,7 +369,7 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
}) {
|
||||
const favorites = useMemo(
|
||||
() => entries
|
||||
.filter((e) => e.favorite && !e.archived && !e.trashed)
|
||||
.filter((e) => e.favorite && !e.archived)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)),
|
||||
[entries],
|
||||
)
|
||||
@@ -392,7 +397,7 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
{!collapsed && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={favIds} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 4 }}>
|
||||
{favorites.map((entry) => {
|
||||
const isActive = isSelectionActive(selection, { kind: 'entity', entry })
|
||||
return (
|
||||
@@ -502,7 +507,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
|
||||
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
|
||||
const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries)
|
||||
const { activeCount, archivedCount } = useEntryCounts(entries)
|
||||
|
||||
const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, [])
|
||||
const closeCustomize = useCallback(() => setShowCustomize(false), [])
|
||||
@@ -559,7 +564,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
|
||||
|
||||
const hasFavorites = entries.some((e) => e.favorite && !e.archived && !e.trashed)
|
||||
const hasFavorites = entries.some((e) => e.favorite && !e.archived)
|
||||
const hasViews = views.length > 0 || !!onCreateView
|
||||
|
||||
return (
|
||||
@@ -571,7 +576,6 @@ export const Sidebar = memo(function Sidebar({
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="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)' }} activeBadgeClassName="bg-primary text-primary-foreground" 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)' }} activeBadgeClassName="bg-destructive text-destructive-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
</div>
|
||||
|
||||
{/* Favorites */}
|
||||
@@ -583,7 +587,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<ViewsSection views={views} selection={selection} onSelect={onSelect} collapsed={groupCollapsed.views} onToggle={() => toggleGroup('views')} onCreateView={onCreateView} onEditView={onEditView} onDeleteView={onDeleteView} />
|
||||
<ViewsSection views={views} selection={selection} onSelect={onSelect} collapsed={groupCollapsed.views} onToggle={() => toggleGroup('views')} onCreateView={onCreateView} onEditView={onEditView} onDeleteView={onDeleteView} entries={entries} />
|
||||
)}
|
||||
|
||||
{/* Types */}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TrashedNoteBanner } from './TrashedNoteBanner'
|
||||
|
||||
describe('TrashedNoteBanner', () => {
|
||||
it('renders the banner with trash message', () => {
|
||||
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
|
||||
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Restore and Delete permanently buttons', () => {
|
||||
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
|
||||
expect(screen.getByText('Restore')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete permanently')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRestore when Restore button is clicked', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<TrashedNoteBanner onRestore={onRestore} onDeletePermanently={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
|
||||
expect(onRestore).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onDeletePermanently when Delete permanently button is clicked', () => {
|
||||
const onDeletePermanently = vi.fn()
|
||||
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={onDeletePermanently} />)
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
|
||||
expect(onDeletePermanently).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { Trash, ArrowCounterClockwise } from '@phosphor-icons/react'
|
||||
|
||||
interface TrashedNoteBannerProps {
|
||||
onRestore: () => void
|
||||
onDeletePermanently: () => void
|
||||
}
|
||||
|
||||
export const TrashedNoteBanner = memo(function TrashedNoteBanner({
|
||||
onRestore,
|
||||
onDeletePermanently,
|
||||
}: TrashedNoteBannerProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-3"
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--destructive-muted, color-mix(in srgb, var(--destructive) 8%, var(--background)))',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
data-testid="trashed-note-banner"
|
||||
>
|
||||
<Trash size={14} style={{ color: 'var(--destructive)', flexShrink: 0 }} />
|
||||
<span className="text-muted-foreground" style={{ flex: 1 }}>This note is in the Trash</span>
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-primary hover:bg-accent"
|
||||
onClick={onRestore}
|
||||
data-testid="trashed-banner-restore"
|
||||
>
|
||||
<ArrowCounterClockwise size={12} />
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-destructive hover:bg-destructive/10"
|
||||
onClick={onDeletePermanently}
|
||||
data-testid="trashed-banner-delete"
|
||||
>
|
||||
<Trash size={12} />
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -16,8 +16,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
@@ -35,7 +33,6 @@ const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
|
||||
makeEntry({ path: '/vault/trashed.md', title: 'Trashed', filename: 'trashed.md', trashed: true }),
|
||||
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
|
||||
]
|
||||
|
||||
@@ -101,13 +98,12 @@ describe('WikilinkChatInput', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('excludes trashed and archived entries from suggestions', async () => {
|
||||
it('excludes archived entries from suggestions', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<Controlled entries={entries} />)
|
||||
await typeAndWait('[[t')
|
||||
await typeAndWait('[[arch')
|
||||
const menu = screen.queryByTestId('wikilink-menu')
|
||||
if (menu) {
|
||||
expect(menu.textContent).not.toContain('Trashed')
|
||||
expect(menu.textContent).not.toContain('Archived')
|
||||
}
|
||||
vi.useRealTimers()
|
||||
|
||||
@@ -49,7 +49,7 @@ function matchEntries(
|
||||
const lower = query.toLowerCase()
|
||||
const matches = entries
|
||||
.filter(e =>
|
||||
!e.trashed && !e.archived && (
|
||||
!e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
|
||||
@@ -10,3 +10,49 @@
|
||||
max-width: 400px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.wikilink-menu__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wikilink-menu__item:hover,
|
||||
.wikilink-menu__item--selected {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.wikilink-menu__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wikilink-menu__type {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Compact variant for FilterBuilder inside dialogs */
|
||||
.wikilink-menu--filter {
|
||||
font-size: 12px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wikilink-menu--filter .wikilink-menu__item {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wikilink-menu--filter .wikilink-menu__type {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { ArrowUpRight, Trash } from '@phosphor-icons/react'
|
||||
import { ArrowUpRight } from '@phosphor-icons/react'
|
||||
import { isEmoji } from '../../utils/emoji'
|
||||
import { entryStatusTitle } from './shared'
|
||||
import { StatusSuffix } from './LinkButton'
|
||||
@@ -14,7 +14,7 @@ function BacklinkEntry({ entry, context, onNavigate }: {
|
||||
context: string | null
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const isDimmed = entry.archived || entry.trashed
|
||||
const isDimmed = entry.archived
|
||||
return (
|
||||
<button
|
||||
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:underline"
|
||||
@@ -25,10 +25,9 @@ function BacklinkEntry({ entry, context, onNavigate }: {
|
||||
className="flex items-center gap-1 text-xs text-primary"
|
||||
style={isDimmed ? { color: 'var(--muted-foreground)' } : undefined}
|
||||
>
|
||||
{entry.trashed && <Trash size={12} className="shrink-0" />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="shrink-0">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
|
||||
<StatusSuffix isArchived={entry.archived} />
|
||||
</span>
|
||||
{context && (
|
||||
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
|
||||
@@ -16,7 +16,7 @@ export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: {
|
||||
const instances = useMemo(() => {
|
||||
if (entry.isA !== 'Type') return []
|
||||
return entries
|
||||
.filter((e) => e.isA === entry.title && !e.trashed)
|
||||
.filter((e) => e.isA === entry.title)
|
||||
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
|
||||
}, [entry, entries])
|
||||
|
||||
@@ -39,7 +39,6 @@ export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: {
|
||||
label={e.title}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
isTrashed={false}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={entryStatusTitle(e)}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import { Trash, X } from '@phosphor-icons/react'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
|
||||
export function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; isTrashed: boolean }) {
|
||||
if (isTrashed) return <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>
|
||||
export function StatusSuffix({ isArchived }: { isArchived: boolean }) {
|
||||
if (isArchived) return <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>
|
||||
return null
|
||||
}
|
||||
|
||||
export function LinkButton({ label, emoji, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: {
|
||||
export function LinkButton({ label, emoji, typeColor, bgColor, isArchived, onClick, onRemove, title, TypeIcon }: {
|
||||
label: string
|
||||
emoji?: string | null
|
||||
typeColor: string
|
||||
bgColor?: string
|
||||
isArchived: boolean
|
||||
isTrashed: boolean
|
||||
onClick: () => void
|
||||
onRemove?: () => void
|
||||
title?: string
|
||||
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
|
||||
}) {
|
||||
const isDimmed = isArchived || isTrashed
|
||||
const isDimmed = isArchived
|
||||
const color = isDimmed ? 'var(--muted-foreground)' : typeColor
|
||||
return (
|
||||
<button
|
||||
@@ -33,10 +31,9 @@ export function LinkButton({ label, emoji, typeColor, bgColor, isArchived, isTra
|
||||
title={title}
|
||||
>
|
||||
<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} />
|
||||
<StatusSuffix isArchived={isArchived} />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{onRemove && (
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
path: '/vault/test.md', filename: 'test.md', title: 'Test', isA: 'Note',
|
||||
aliases: [], outgoingLinks: [], relationships: {}, tags: [],
|
||||
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 1024,
|
||||
icon: null, color: null, archived: false, trashed: false, favorite: false,
|
||||
icon: null, color: null, archived: false, favorite: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,8 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
label={e.title}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
isTrashed={e.trashed}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -217,7 +217,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
if (refs.length === 0) return null
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
<span className="font-mono-overline mb-1 block text-muted-foreground">{label}</span>
|
||||
<span className="mb-1 block text-[12px] text-muted-foreground">{label}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{refs.map((ref, idx) => {
|
||||
const props = resolveRefProps(ref, entries, typeEntryMap)
|
||||
@@ -368,7 +368,7 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
onSubmitWithCreate={handleCreateAndSubmit}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
|
||||
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()} data-testid="submit-add-relationship">Add</button>
|
||||
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -402,32 +402,15 @@ function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote
|
||||
onAdd: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const [active, setActive] = useState(false)
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
<span className="font-mono-overline mb-1 block text-muted-foreground/50">{label}</span>
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
onAdd={(noteTitle) => { onAdd(noteTitle); setActive(false) }}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="mb-2.5 flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-0 py-1 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary"
|
||||
tabIndex={0}
|
||||
onClick={() => setActive(true)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setActive(true) } }}
|
||||
data-testid="suggested-relationship"
|
||||
>
|
||||
<span className="font-mono-overline text-muted-foreground/50">{label}</span>
|
||||
<span className="text-[12px] text-muted-foreground/30">{'\u2014'}</span>
|
||||
</button>
|
||||
<div className="mb-2.5" data-testid="suggested-relationship">
|
||||
<span className="mb-1 block text-[12px] text-muted-foreground/50">{label}</span>
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
onAdd={onAdd}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | und
|
||||
}
|
||||
|
||||
export function entryStatusTitle(entry: VaultEntry | undefined): string | undefined {
|
||||
if (entry?.trashed) return 'Trashed'
|
||||
if (entry?.archived) return 'Archived'
|
||||
return undefined
|
||||
}
|
||||
@@ -38,7 +37,6 @@ export function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap
|
||||
typeColor: getTypeColor(refType, te?.color),
|
||||
bgColor: getTypeLightColor(refType, te?.color),
|
||||
isArchived: resolved?.archived ?? false,
|
||||
isTrashed: resolved?.trashed ?? false,
|
||||
target: wikilinkTarget(ref),
|
||||
title: entryStatusTitle(resolved),
|
||||
TypeIcon: getTypeIcon(refType, te?.icon),
|
||||
|
||||
@@ -11,7 +11,6 @@ interface FilterPillsProps {
|
||||
const PILLS: { value: NoteListFilter; label: string }[] = [
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
{ value: 'trashed', label: 'Trashed' },
|
||||
]
|
||||
|
||||
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MagnifyingGlass, Plus, Trash } from '@phosphor-icons/react'
|
||||
import { MagnifyingGlass, Plus } from '@phosphor-icons/react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -6,12 +6,10 @@ import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { SortDropdown } from '../SortDropdown'
|
||||
import { ListPropertiesPopover } from './ListPropertiesPopover'
|
||||
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash, onUpdateTypeProperty }: {
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onUpdateTypeProperty }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
isTrashView: boolean
|
||||
trashCount: number
|
||||
listSort: SortOption
|
||||
listDirection: SortDirection
|
||||
customProperties: string[]
|
||||
@@ -25,7 +23,6 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onEmptyTrash?: () => void
|
||||
onUpdateTypeProperty?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
@@ -48,21 +45,9 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
|
||||
{isSectionGroup && typeDocument && entries && onUpdateTypeProperty && (
|
||||
<ListPropertiesPopover typeDocument={typeDocument} entries={entries} onSave={onUpdateTypeProperty} />
|
||||
)}
|
||||
{isTrashView && trashCount > 0 && (
|
||||
<button
|
||||
className="flex items-center text-destructive transition-colors hover:text-destructive/80"
|
||||
onClick={onEmptyTrash}
|
||||
title="Empty Trash"
|
||||
data-testid="empty-trash-btn"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
{!isTrashView && (
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{searchVisible && (
|
||||
|
||||
@@ -3,18 +3,11 @@ import type { VaultEntry } from '../../types'
|
||||
import type { SortOption, SortDirection, SortConfig, RelationshipGroup } from '../../utils/noteListHelpers'
|
||||
import { PinnedCard } from './PinnedCard'
|
||||
import { RelationshipGroupSection } from './RelationshipGroupSection'
|
||||
import { TrashWarningBanner, EmptyMessage } from './TrashWarningBanner'
|
||||
import { EmptyMessage } from './TrashWarningBanner'
|
||||
|
||||
function ListViewHeader({ isTrashView, expiredTrashCount }: {
|
||||
isTrashView: boolean; expiredTrashCount: number
|
||||
}) {
|
||||
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
}
|
||||
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, isInboxView: boolean, query: string): string {
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, 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'
|
||||
@@ -40,20 +33,18 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
|
||||
isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null
|
||||
deletedCount?: number; searched: VaultEntry[]; query: string
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
|
||||
}) {
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, !!isInboxView, query)
|
||||
const hasHeader = isTrashView && expiredTrashCount > 0
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, !!isArchivedView, !!isInboxView, query)
|
||||
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0
|
||||
|
||||
if (searched.length === 0 && !hasDeletedOnly) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{hasHeader && <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} />}
|
||||
<EmptyMessage text={emptyText} />
|
||||
</div>
|
||||
)
|
||||
@@ -69,9 +60,6 @@ export function ListView({ isTrashView, isArchivedView, isChangesView, isInboxVi
|
||||
style={{ height: '100%' }}
|
||||
data={searched}
|
||||
overscan={200}
|
||||
components={{
|
||||
Header: hasHeader ? () => <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} /> : undefined,
|
||||
}}
|
||||
itemContent={(_index, entry) => renderItem(entry)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
import { Warning, TrashSimple } from '@phosphor-icons/react'
|
||||
|
||||
export function TrashWarningBanner({ expiredCount }: { expiredCount: number }) {
|
||||
if (expiredCount === 0) return null
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-[var(--border)]" style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}>
|
||||
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>Notes in trash for 30+ days will be permanently deleted</div>
|
||||
<div className="text-muted-foreground" style={{ fontSize: 11 }}>{expiredCount} {expiredCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { TrashSimple } from '@phosphor-icons/react'
|
||||
|
||||
export function EmptyMessage({ text }: { text: string }) {
|
||||
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">{text}</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '../../utils/noteListHelpers'
|
||||
import type { InboxPeriod } from '../../types'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
|
||||
import { filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
|
||||
// --- useTypeEntryMap ---
|
||||
@@ -45,7 +45,6 @@ interface NoteListDataParams {
|
||||
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
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, subFilter, inboxPeriod, views)
|
||||
@@ -64,12 +63,7 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
|
||||
return filterGroupsByQuery(groups, query)
|
||||
}, [isEntityView, selection, entries, query])
|
||||
|
||||
const expiredTrashCount = useMemo(
|
||||
() => isTrashView ? countExpiredTrash(searched) : 0,
|
||||
[isTrashView, searched],
|
||||
)
|
||||
|
||||
return { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount }
|
||||
return { isEntityView, isArchivedView, searched, searchedGroups }
|
||||
}
|
||||
|
||||
// --- useNoteListSearch ---
|
||||
@@ -193,22 +187,22 @@ function handleSelectAllKey(e: KeyboardEvent, multiSelect: MultiSelectState, isE
|
||||
multiSelect.selectAll()
|
||||
}
|
||||
|
||||
function handleBulkActionKey(e: KeyboardEvent, multiSelect: MultiSelectState, onArchive: () => void, onTrash: () => void) {
|
||||
function handleBulkActionKey(e: KeyboardEvent, multiSelect: MultiSelectState, onArchive: () => void, onDelete: () => void) {
|
||||
if (!multiSelect.isMultiSelecting || !(e.metaKey || e.ctrlKey)) return
|
||||
if (e.key === 'e') { e.preventDefault(); e.stopPropagation(); onArchive() }
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); onTrash() }
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); onDelete() }
|
||||
}
|
||||
|
||||
export function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boolean, onBulkArchive: () => void, onBulkTrash: () => void) {
|
||||
export function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boolean, onBulkArchive: () => void, onBulkDelete: () => void) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
handleEscapeKey(e, multiSelect)
|
||||
handleSelectAllKey(e, multiSelect, isEntityView)
|
||||
handleBulkActionKey(e, multiSelect, onBulkArchive, onBulkTrash)
|
||||
handleBulkActionKey(e, multiSelect, onBulkArchive, onBulkDelete)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [multiSelect, isEntityView, onBulkArchive, onBulkTrash])
|
||||
}, [multiSelect, isEntityView, onBulkArchive, onBulkDelete])
|
||||
}
|
||||
|
||||
// --- useModifiedFilesState ---
|
||||
|
||||
@@ -6,7 +6,7 @@ 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,
|
||||
archived: false,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null,
|
||||
|
||||
@@ -9,7 +9,6 @@ export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: Va
|
||||
if (selection.kind === 'entity') return selection.entry.title
|
||||
if (typeDocument) return typeDocument.title
|
||||
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'
|
||||
@@ -24,11 +23,6 @@ export function filterGroupsByQuery(groups: RelationshipGroup[], query: string):
|
||||
return groups.map((g) => ({ ...g, entries: filterByQuery(g.entries, query) })).filter((g) => g.entries.length > 0)
|
||||
}
|
||||
|
||||
export function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
const now = Date.now() / 1000
|
||||
return entries.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
|
||||
}
|
||||
|
||||
export interface ClickActions {
|
||||
onReplace: (entry: VaultEntry) => void
|
||||
onSelect: (entry: VaultEntry) => void
|
||||
|
||||
@@ -61,7 +61,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[12000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[12000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
@@ -19,7 +19,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
path: '/test/note.md', filename: 'note.md', title: 'Test Note',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null, archived: false,
|
||||
trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null,
|
||||
|
||||
@@ -12,6 +12,5 @@ export function buildFilterCommands(config: FilterCommandsConfig): CommandAction
|
||||
return [
|
||||
{ id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') },
|
||||
{ id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') },
|
||||
{ id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') },
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user