Compare commits

...

12 Commits

Author SHA1 Message Date
Test
d378e488f1 fix: remove remaining trash navigation command and header title
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:27:15 +02:00
Test
e581ad3691 refactor: remove Trash system — delete is now permanent with confirm modal
Remove all vestiges of the abandoned Trash system: trashed/trashedAt fields
from types, frontmatter parsing, sidebar filtering, editor banners, inspector
components, mock data, and all related tests. Delete is already permanent via
useDeleteActions with a confirmation dialog. Notes with trashed:true in
existing vault frontmatter are now treated as normal notes (the flag is
ignored by the parser).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:21:56 +02:00
Test
09f7498e86 fix: Cmd+Option+arrow navigation now follows the visible note list order
The keyboard navigation was computing its own note list with hardcoded
sortByModified, which didn't match the actual UI order (Inbox sorts by
createdAt, custom types use configurable sorts, etc.). Now the NoteList
component writes its sorted list to a shared ref that the navigation
hook reads directly, ensuring navigation always matches visual order.

Also fixes navigation not wrapping at list boundaries per spec.

Syncs CodeScene thresholds with current remote scores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:29:27 +02:00
Test
7443b9a468 fix: show Add button immediately in suggested relationship slots
Remove the collapsed/expanded toggle from SuggestedRelationshipSlot so
the InlineAddNote "Add" button with dashed border is visible without
a click. Also change relationship label font-size from font-mono-overline
(10px) to text-[12px] to match property names in the Inspector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:59:34 +02:00
Test
12f8fad0f0 fix: align suggested property slots with real properties in Inspector
Add text-left to the suggested property button (browsers default buttons
to text-align: center) and remove text-right from the em-dash placeholder
so both label and value columns match the real property row alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:27:08 +02:00
Test
c90b1d6694 fix: add bottom padding to Favorites list matching top padding
Added paddingBottom: 4 to the expanded Favorites list container,
consistent with the ViewsSection pattern. This creates symmetric
spacing above the first and below the last favorite item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:09:29 +02:00
Test
806b552d47 fix: default sidebar section to Inbox on app launch and vault switch
Changed DEFAULT_SELECTION from 'all' (All Notes) to 'inbox' so the
app always opens on the Inbox section. During a session the user's
navigation is preserved; on next launch or vault switch it resets
to Inbox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:44:18 +02:00
Test
2cd192fae6 style: apply rustfmt to classify_file_kind tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:19:44 +02:00
Test
46106da47a fix: pass forceRawMode through ActiveTabBreadcrumb to BreadcrumbBar
TypeScript error: forceRawMode was set in the props object but not
declared in the ActiveTabBreadcrumb type or forwarded to BreadcrumbBar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:15:11 +02:00
Test
0051559b8e fix: hide Raw toggle for non-markdown files forced into raw editor
Non-markdown text files (.yml, .yaml, .json, etc.) are already forced
into raw editor mode via effectiveRawMode. This hides the Raw toggle
button in the breadcrumb bar for those files since toggling is not
applicable. Adds tests for classify_file_kind and forceRawMode behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:59:19 +02:00
Test
3f19528cb6 fix: wikilink filter values now match frontmatter format with alias support
The FilterBuilder autocomplete now stores wikilinks as [[stem|title]]
(e.g., [[monday-112|Monday 112]]) instead of just [[title]]. The view
filter comparison uses wikilinkEquals() to match any combination of
path and alias parts, so [[monday-112|Monday 112]] matches
[[monday-112|Monday #112]] via the shared path "monday-112".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:29:14 +02:00
Test
24e5b537d6 fix: wikilink autocomplete in FilterBuilder no longer clipped by dialog
The dropdown was position:absolute inside a scrollable container with
overflow-y:auto, causing it to be clipped. Now uses createPortal to
render to document.body with position:fixed, escaping the dialog's
overflow. Also adds proper CSS for menu items (font-size 12px, aligned
icon+text, compact padding) via .wikilink-menu--filter modifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:58:08 +02:00
129 changed files with 694 additions and 3288 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.41
AVERAGE_THRESHOLD=9.30
HOTSPOT_THRESHOLD=9.33
AVERAGE_THRESHOLD=9.27

View File

@@ -1,5 +1,5 @@
---
title: Untitled note 343
title: Untitled note 345
type: Note
status: Active
---

View File

@@ -1,5 +1,5 @@
---
title: Untitled note 342
title: Untitled note 352
type: Note
status: Active
---

View File

@@ -1,5 +1,5 @@
---
title: Untitled note 345
title: Untitled note 343
type: Note
status: Active
---

View File

@@ -0,0 +1,6 @@
---
title: Untitled note 342
type: Note
status: Active
---
My Custom H1 Heading

View File

@@ -0,0 +1,5 @@
---
title: Untitled note 346
type: Note
status: Active
---

View File

@@ -0,0 +1,5 @@
---
title: Untitled note 347
type: Note
status: Active
---

View File

@@ -0,0 +1,5 @@
---
title: Untitled note 348
type: Note
status: Active
---

View File

@@ -0,0 +1,6 @@
---
title: Untitled note 349
type: Note
status: Active
---
My Custom H1 Heading

View File

@@ -0,0 +1,5 @@
---
title: Untitled note 350
type: Note
status: Active
---

View File

@@ -0,0 +1,5 @@
---
title: Untitled note 351
type: Note
status: Active
---

View File

@@ -0,0 +1,8 @@
---
title: Untitled note 353
type: Project
status: Active
---
Appended by raw editor test

View File

@@ -0,0 +1,8 @@
---
title: Untitled note 354
type: Note
status: Active
---
[[2024-03|March 2024]]
[[2024-03|March 2024]]

View File

@@ -0,0 +1,16 @@
---
title: Untitled project 57
type: Project
status: Active
---
## Objective
## Key Results
## Notes

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -60,12 +60,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 +72,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 +218,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 +268,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(&note).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 +318,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 +333,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 +345,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"
);
}

View File

@@ -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| {
@@ -158,14 +147,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 +182,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()),
);
}
});
}
}
_ => {}
}
}
});

View File

@@ -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")
@@ -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,

View File

@@ -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()

View File

@@ -888,18 +888,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 +907,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 +936,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]

View File

@@ -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")]

View File

@@ -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)]
@@ -158,12 +148,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",
@@ -199,11 +183,6 @@ const SKIP_KEYS: &[&str] = &[
"aliases",
"_archived",
"archived",
"_trashed",
"trashed",
"_trashed_at",
"trashed at",
"trashed_at",
"icon",
"color",
"order",

View File

@@ -23,7 +23,7 @@ pub use rename::{
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,
@@ -98,12 +98,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,

View File

@@ -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,43 @@ 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");
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -285,32 +285,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::*;
@@ -747,50 +721,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]

View File

@@ -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());
}
}

View File

@@ -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),
_ => {}
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -15,8 +15,6 @@ const baseEntry: VaultEntry = {
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 100,
@@ -33,12 +31,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 +47,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()
})
})
@@ -174,4 +152,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()
})
})

View File

@@ -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"

View File

@@ -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', () => {

View File

@@ -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)' }}

View File

@@ -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()

View File

@@ -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>
)
}

View File

@@ -66,8 +66,6 @@ const mockEntry: VaultEntry = {
owner: 'Luca',
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
@@ -236,71 +234,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 +276,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', () => {

View File

@@ -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}

View File

@@ -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,16 +158,13 @@ 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
// Non-markdown text files always use the raw editor (no BlockNote)
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
@@ -222,14 +216,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,17 +235,17 @@ export function EditorContent({
<div ref={titleSectionRef} className="title-section">
{!emojiIcon && (
<div className="title-section__add-icon">
<NoteIcon icon={null} editable={!isTrashed} onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
<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={!isTrashed} onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
<NoteIcon icon={emojiIcon} editable onSetIcon={handleSetIcon} onRemoveIcon={handleRemoveIcon} />
)}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable={!isTrashed}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
@@ -265,7 +253,7 @@ export function EditorContent({
</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>
)}

View File

@@ -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' }],

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 }),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>
)}
</>
)}

View File

@@ -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 })
})
})

View File

@@ -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}

View File

@@ -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,

View File

@@ -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: [],

View File

@@ -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',

View File

@@ -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,

View File

@@ -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> = {

View File

@@ -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} />)

View File

@@ -100,13 +100,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])
}
@@ -314,7 +313,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 +363,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 +391,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 +501,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 +558,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 +570,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 */}

View File

@@ -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',

View File

@@ -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()
})
})

View File

@@ -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>
)
})

View File

@@ -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()

View File

@@ -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))
),

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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)}

View File

@@ -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 && (

View File

@@ -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,
}
}

View File

@@ -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)}
/>
)

View File

@@ -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>
)
}

View File

@@ -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),

View File

@@ -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%)'

View File

@@ -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 && (

View File

@@ -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)}
/>
)

View File

@@ -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>

View File

@@ -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 ---

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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') },
]
}

View File

@@ -17,7 +17,6 @@ export function buildNavigationCommands(config: NavigationCommandsConfig): Comma
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },

View File

@@ -4,18 +4,14 @@ interface NoteCommandsConfig {
hasActiveNote: boolean
activeTabPath: string | null
isArchived: boolean
isTrashed: boolean
activeNoteHasIcon?: boolean
trashedCount?: number
onCreateNote: () => void
onCreateType?: () => void
onOpenDailyNote: () => void
onSave: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onEmptyTrash?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
@@ -27,10 +23,9 @@ interface NoteCommandsConfig {
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeTabPath, isArchived, isTrashed,
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onOpenDailyNote, onSave,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onEmptyTrash, trashedCount,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized,
@@ -41,11 +36,10 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
{
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
id: 'delete-note', label: 'Delete Note', group: 'Note', shortcut: '⌘⌫',
keywords: ['delete', 'remove'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) onDeleteNote(activeTabPath) },
},
{
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',

View File

@@ -18,7 +18,6 @@ export function pluralizeType(type: string): string {
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
for (const e of entries) {
if (e.trashed) continue
if (e.isA === 'Type' && e.title) {
typeSet.add(e.title)
} else if (e.isA && e.isA !== 'Type') {

View File

@@ -12,7 +12,6 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
icon: { icon: null }, sidebar_label: { sidebarLabel: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
_archived: { archived: false }, archived: { archived: false },
_trashed: { trashed: false }, trashed: { trashed: false },
order: { order: null },
template: { template: null }, sort: { sort: null }, visible: { visible: null },
_organized: { organized: false },
@@ -60,7 +59,6 @@ export function frontmatterToEntryPatch(
icon: { icon: str }, sidebar_label: { sidebarLabel: str },
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
_archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) },
_trashed: { trashed: Boolean(value) }, trashed: { trashed: Boolean(value) },
order: { order: typeof value === 'number' ? value : null },
template: { template: str },
sort: { sort: str },

View File

@@ -12,6 +12,7 @@ interface AppCommandsConfig {
activeTabPath: string | null
activeTabPathRef: React.MutableRefObject<string | null>
entries: VaultEntry[]
visibleNotesRef: React.RefObject<VaultEntry[]>
modifiedCount: number
selection: SidebarSelection
onQuickOpen: () => void
@@ -22,8 +23,7 @@ interface AppCommandsConfig {
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
@@ -57,8 +57,6 @@ interface AppCommandsConfig {
onInstallMcp?: () => void
claudeCodeStatus?: string
claudeCodeVersion?: string
onEmptyTrash?: () => void
trashedCount?: number
onReloadVault?: () => void
onRepairVault?: () => void
onSetNoteIcon?: () => void
@@ -82,10 +80,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
;(entry?.archived ? config.onUnarchiveNote : config.onArchiveNote)(path)
}, [config.onArchiveNote, config.onUnarchiveNote])
const toggleTrash = useCallback((path: string) => {
const entry = entriesRef.current.find(e => e.path === path)
;(entry?.trashed ? config.onRestoreNote : config.onTrashNote)(path)
}, [config.onTrashNote, config.onRestoreNote])
const { onSelect } = config
@@ -105,7 +99,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onOpenDailyNote: config.onOpenDailyNote,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: toggleTrash,
onDeleteNote: config.onDeleteNote,
onArchiveNote: toggleArchive,
onSetViewMode: config.onSetViewMode,
onZoomIn: config.onZoomIn,
@@ -135,7 +129,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
onArchiveNote: toggleArchive,
onTrashNote: toggleTrash,
onDeleteNote: config.onDeleteNote,
onSearch: config.onSearch,
onToggleRawEditor: config.onToggleRawEditor,
onToggleDiff: config.onToggleDiff,
@@ -154,7 +148,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onInstallMcp: config.onInstallMcp,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onEmptyTrash: config.onEmptyTrash,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
activeTabPath: config.activeTabPath,
@@ -170,8 +163,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCreateNoteOfType: config.onCreateNoteOfType,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,
onRestoreNote: config.onRestoreNote,
onDeleteNote: config.onDeleteNote,
onArchiveNote: config.onArchiveNote,
onUnarchiveNote: config.onUnarchiveNote,
onCommitPush: config.onCommitPush,
@@ -202,8 +194,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onEmptyTrash: config.onEmptyTrash,
trashedCount: config.trashedCount,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onSetNoteIcon: config.onSetNoteIcon,
@@ -219,8 +209,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
useKeyboardNavigation({
activeTabPath: config.activeTabPath,
entries: config.entries,
selection: config.selection,
visibleNotesRef: config.visibleNotesRef,
onReplaceActiveTab: config.onReplaceActiveTab,
onSelectNote: config.onSelectNote,
})

View File

@@ -24,7 +24,7 @@ function makeActions() {
onOpenDailyNote: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
onSetViewMode: vi.fn(),
onZoomIn: vi.fn(),
@@ -130,20 +130,20 @@ describe('useAppKeyboard', () => {
try { fn() } finally { document.body.removeChild(input) }
}
it('Cmd+Backspace does not trash note when text input is focused', () => {
it('Cmd+Backspace does not delete note when text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedInput(() => {
fireKey('Backspace', { metaKey: true })
expect(actions.onTrashNote).not.toHaveBeenCalled()
expect(actions.onDeleteNote).not.toHaveBeenCalled()
})
})
it('Cmd+Backspace trashes note when no text input is focused', () => {
it('Cmd+Backspace deletes note when no text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('Backspace', { metaKey: true })
expect(actions.onTrashNote).toHaveBeenCalledWith('/vault/test.md')
expect(actions.onDeleteNote).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+K still works when text input is focused', () => {

View File

@@ -10,7 +10,7 @@ interface KeyboardActions {
onOpenDailyNote: () => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onSetViewMode: (mode: ViewMode) => void
onZoomIn: () => void
@@ -66,7 +66,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
}
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onDeleteNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onToggleFavorite, onOpenInNewWindow, activeTabPathRef,
}: KeyboardActions) {
useEffect(() => {
@@ -84,8 +84,8 @@ export function useAppKeyboard({
',': onOpenSettings,
d: withActiveTab((path) => onToggleFavorite?.(path)),
e: withActiveTab(onArchiveNote),
Backspace: withActiveTab(onTrashNote),
Delete: withActiveTab(onTrashNote),
Backspace: withActiveTab(onDeleteNote),
Delete: withActiveTab(onDeleteNote),
'[': () => onGoBack?.(),
']': () => onGoForward?.(),
'=': onZoomIn,
@@ -127,5 +127,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onOpenInNewWindow])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onDeleteNote, onArchiveNote, activeTabPathRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onOpenInNewWindow])
}

View File

@@ -4,21 +4,17 @@ import { useBulkActions } from './useBulkActions'
describe('useBulkActions', () => {
let handleArchiveNote: ReturnType<typeof vi.fn>
let handleTrashNote: ReturnType<typeof vi.fn>
let handleRestoreNote: ReturnType<typeof vi.fn>
let setToastMessage: ReturnType<typeof vi.fn>
beforeEach(() => {
handleArchiveNote = vi.fn().mockResolvedValue(undefined)
handleTrashNote = vi.fn().mockResolvedValue(undefined)
handleRestoreNote = vi.fn().mockResolvedValue(undefined)
setToastMessage = vi.fn()
})
function renderBulkActions() {
return renderHook(() =>
useBulkActions(
{ handleArchiveNote, handleTrashNote, handleRestoreNote },
{ handleArchiveNote },
setToastMessage,
),
)
@@ -77,104 +73,4 @@ describe('useBulkActions', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
})
// --- handleBulkTrash ---
describe('handleBulkTrash', () => {
it('trashes each path and shows plural toast', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
})
expect(handleTrashNote).toHaveBeenCalledTimes(2)
expect(setToastMessage).toHaveBeenCalledWith('2 notes moved to trash')
})
it('shows singular toast when one note trashed', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash([])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
it('skips failed paths and counts only successes', async () => {
handleTrashNote
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce(undefined)
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
})
it('shows no toast when all fail', async () => {
handleTrashNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkTrash(['/vault/a.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
// --- handleBulkRestore ---
describe('handleBulkRestore', () => {
it('restores each path and shows plural toast', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('3 notes restored')
})
it('shows singular toast when one note restored', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/only.md'])
})
expect(setToastMessage).toHaveBeenCalledWith('1 note restored')
})
it('does not show toast when empty array given', async () => {
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore([])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
it('partial failure: counts only successful restores in toast', async () => {
handleRestoreNote
.mockResolvedValueOnce(undefined) // /vault/a.md ok
.mockRejectedValueOnce(new Error('not found')) // /vault/b.md fails
.mockResolvedValueOnce(undefined) // /vault/c.md ok
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
})
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
expect(setToastMessage).toHaveBeenCalledWith('2 notes restored')
})
it('shows no toast when all restores fail', async () => {
handleRestoreNote.mockRejectedValue(new Error('fail'))
const { result } = renderBulkActions()
await act(async () => {
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md'])
})
expect(setToastMessage).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,8 +2,6 @@ import { useCallback } from 'react'
interface BulkEntryActions {
handleArchiveNote: (path: string) => Promise<void>
handleTrashNote: (path: string) => Promise<void>
handleRestoreNote: (path: string) => Promise<void>
}
export function useBulkActions(
@@ -19,23 +17,5 @@ export function useBulkActions(
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleTrashNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
const handleBulkRestore = useCallback(async (paths: string[]) => {
let ok = 0
for (const path of paths) {
try { await entryActions.handleRestoreNote(path); ok++ }
catch { /* skip — error toast already shown */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash, handleBulkRestore }
return { handleBulkArchive }
}

View File

@@ -13,8 +13,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onCreateNoteOfType: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
onRestoreNote: vi.fn(),
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
onUnarchiveNote: vi.fn(),
onCommitPush: vi.fn(),
@@ -186,16 +185,9 @@ describe('extractVaultTypes', () => {
expect(types).toHaveLength(2)
})
it('excludes trashed entries', () => {
const entries = [
{ path: '/a', title: 'A', isA: 'Project', trashed: true },
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Event', 'Person', 'Project', 'Note'])
})
it('includes types from Type definition entries', () => {
const entries = [
{ path: '/book.md', title: 'Book', isA: 'Type', trashed: false },
{ path: '/book.md', title: 'Book', isA: 'Type' },
] as never[]
const types = extractVaultTypes(entries)
expect(types).toContain('Book')
@@ -203,9 +195,9 @@ describe('extractVaultTypes', () => {
it('includes types from both definitions and instances', () => {
const entries = [
{ path: '/book.md', title: 'Book', isA: 'Type', trashed: false },
{ path: '/hp.md', title: 'Harry Potter', isA: 'Book', trashed: false },
{ path: '/person.md', title: 'Person', isA: 'Type', trashed: false },
{ path: '/book.md', title: 'Book', isA: 'Type' },
{ path: '/hp.md', title: 'Harry Potter', isA: 'Book' },
{ path: '/person.md', title: 'Person', isA: 'Type' },
] as never[]
const types = extractVaultTypes(entries)
expect(types).toContain('Book')
@@ -213,12 +205,6 @@ describe('extractVaultTypes', () => {
expect(types).toHaveLength(2)
})
it('excludes trashed Type definition entries', () => {
const entries = [
{ path: '/book.md', title: 'Book', isA: 'Type', trashed: true },
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Event', 'Person', 'Project', 'Note'])
})
})
describe('groupSortKey', () => {

View File

@@ -23,8 +23,6 @@ interface CommandRegistryConfig {
activeNoteHasIcon?: boolean
mcpStatus?: string
onInstallMcp?: () => void
onEmptyTrash?: () => void
trashedCount?: number
onReloadVault?: () => void
onRepairVault?: () => void
onSetNoteIcon?: () => void
@@ -39,8 +37,7 @@ interface CommandRegistryConfig {
onOpenSettings: () => void
onOpenVault?: () => void
onCreateType?: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
@@ -76,7 +73,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
@@ -84,7 +81,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates, onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
mcpStatus, onInstallMcp,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
@@ -98,7 +95,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
[entries, activeTabPath, hasActiveNote],
)
const isArchived = activeEntry?.archived ?? false
const isTrashed = activeEntry?.trashed ?? false
const isFavorite = activeEntry?.favorite ?? false
const isSectionGroup = selection?.kind === 'sectionGroup'
@@ -107,10 +103,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
return useMemo(() => [
...buildNavigationCommands({ onQuickOpen, onSelect, onOpenDailyNote, onGoBack, onGoForward, canGoBack, canGoForward }),
...buildNoteCommands({
hasActiveNote, activeTabPath, isArchived, isTrashed,
hasActiveNote, activeTabPath, isArchived,
onCreateNote, onCreateType, onOpenDailyNote, onSave,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
}),
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
@@ -126,9 +122,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }),
], [
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
@@ -136,7 +132,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
mcpStatus, onInstallMcp,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
isSectionGroup, noteListFilter, onSetNoteListFilter,

View File

@@ -10,10 +10,6 @@ vi.mock('../mock-tauri', () => ({
const { mockInvoke } = await import('../mock-tauri')
const mockInvokeFn = mockInvoke as ReturnType<typeof vi.fn>
function makeEntry(path: string, trashed = false) {
return { path, trashed } as { path: string; trashed: boolean }
}
describe('useDeleteActions', () => {
let onDeselectNote: ReturnType<typeof vi.fn>
let removeEntry: ReturnType<typeof vi.fn>
@@ -26,11 +22,9 @@ describe('useDeleteActions', () => {
mockInvokeFn.mockReset()
})
function renderDeleteActions(entries = [makeEntry('/vault/a.md'), makeEntry('/vault/t.md', true)]) {
function renderDeleteActions() {
return renderHook(() =>
useDeleteActions({
vaultPath: '/vault',
entries,
onDeselectNote,
removeEntry,
setToastMessage,
@@ -38,24 +32,6 @@ describe('useDeleteActions', () => {
)
}
// --- trashedCount ---
describe('trashedCount', () => {
it('counts trashed entries', () => {
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/b.md', true),
makeEntry('/vault/c.md', true),
])
expect(result.current.trashedCount).toBe(2)
})
it('returns 0 when no trashed entries', () => {
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
expect(result.current.trashedCount).toBe(0)
})
})
// --- deleteNoteFromDisk ---
describe('deleteNoteFromDisk', () => {
@@ -145,65 +121,6 @@ describe('useDeleteActions', () => {
})
})
// --- handleEmptyTrash ---
describe('handleEmptyTrash', () => {
it('does nothing when trashedCount is 0', () => {
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
act(() => {
result.current.handleEmptyTrash()
})
expect(result.current.confirmDelete).toBeNull()
})
it('sets confirmDelete with trash count in message', () => {
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/t1.md', true),
makeEntry('/vault/t2.md', true),
])
act(() => {
result.current.handleEmptyTrash()
})
expect(result.current.confirmDelete?.title).toBe('Empty Trash?')
expect(result.current.confirmDelete?.confirmLabel).toBe('Empty Trash')
})
it('onConfirm invokes empty_trash, closes tabs, removes entries', async () => {
mockInvokeFn.mockResolvedValue(['/vault/t1.md', '/vault/t2.md'])
const { result } = renderDeleteActions([
makeEntry('/vault/a.md'),
makeEntry('/vault/t1.md', true),
makeEntry('/vault/t2.md', true),
])
act(() => {
result.current.handleEmptyTrash()
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(result.current.confirmDelete).toBeNull()
expect(mockInvokeFn).toHaveBeenCalledWith('empty_trash', { vaultPath: '/vault' })
expect(onDeselectNote).toHaveBeenCalledWith('/vault/t1.md')
expect(onDeselectNote).toHaveBeenCalledWith('/vault/t2.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t1.md')
expect(removeEntry).toHaveBeenCalledWith('/vault/t2.md')
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
})
it('shows error toast when empty_trash fails', async () => {
mockInvokeFn.mockRejectedValue(new Error('oops'))
const { result } = renderDeleteActions([makeEntry('/vault/t.md', true)])
act(() => {
result.current.handleEmptyTrash()
})
await act(async () => {
await result.current.confirmDelete!.onConfirm()
})
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to empty trash'))
})
})
// --- setConfirmDelete ---
describe('setConfirmDelete', () => {

View File

@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { trackEvent } from '../lib/telemetry'
interface ConfirmDeleteState {
@@ -12,8 +11,6 @@ interface ConfirmDeleteState {
}
interface UseDeleteActionsInput {
vaultPath: string
entries: VaultEntry[]
/** Called to deselect the note if it is currently open. */
onDeselectNote: (path: string) => void
removeEntry: (path: string) => void
@@ -21,16 +18,12 @@ interface UseDeleteActionsInput {
}
export function useDeleteActions({
vaultPath,
entries,
onDeselectNote,
removeEntry,
setToastMessage,
}: UseDeleteActionsInput) {
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState | null>(null)
const trashedCount = useMemo(() => entries.filter(e => e.trashed).length, [entries])
const deleteNoteFromDisk = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
@@ -47,7 +40,7 @@ export function useDeleteActions({
const handleDeleteNote = useCallback(async (path: string) => {
setConfirmDelete({
title: 'Delete permanently?',
message: 'This note will be permanently deleted. This cannot be undone.',
message: 'Delete permanently? This cannot be undone. You can recover it from Git history.',
onConfirm: async () => {
setConfirmDelete(null)
const ok = await deleteNoteFromDisk(path)
@@ -75,36 +68,11 @@ export function useDeleteActions({
})
}, [deleteNoteFromDisk, setToastMessage])
const handleEmptyTrash = useCallback(() => {
if (trashedCount === 0) return
setConfirmDelete({
title: 'Empty Trash?',
message: `Permanently delete all ${trashedCount} trashed ${trashedCount === 1 ? 'note' : 'notes'}? This cannot be undone.`,
confirmLabel: 'Empty Trash',
onConfirm: async () => {
setConfirmDelete(null)
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const deleted = await tauriInvoke<string[]>('empty_trash', { vaultPath })
for (const path of deleted) {
onDeselectNote(path)
removeEntry(path)
}
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
} catch (e) {
setToastMessage(`Failed to empty trash: ${e}`)
}
},
})
}, [trashedCount, vaultPath, onDeselectNote, removeEntry, setToastMessage])
return {
confirmDelete,
setConfirmDelete,
trashedCount,
deleteNoteFromDisk,
handleDeleteNote,
handleBulkDeletePermanently,
handleEmptyTrash,
}
}

View File

@@ -13,8 +13,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
relatedTo: [],
status: 'Active',
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
@@ -59,58 +57,6 @@ describe('useEntryActions', () => {
vi.clearAllMocks()
})
describe('handleTrashNote', () => {
it('sets trashed frontmatter and updates entry state', async () => {
const { result } = setup()
await act(async () => {
await result.current.handleTrashNote('/vault/note/test.md')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_trashed', true, { silent: true })
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_trashed_at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), { silent: true })
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
trashed: true,
trashedAt: expect.any(Number),
})
expect(setToastMessage).toHaveBeenCalledWith('Note moved to trash')
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
it('final toast is contextual, not "Property updated"', async () => {
const { result } = setup()
const toastCalls: (string | null)[] = []
setToastMessage.mockImplementation((msg: string | null) => toastCalls.push(msg))
await act(async () => {
await result.current.handleTrashNote('/vault/note/test.md')
})
// The only toast should be "Note moved to trash", never "Property updated"
expect(toastCalls).toEqual(['Note moved to trash'])
})
})
describe('handleRestoreNote', () => {
it('clears trashed frontmatter and updates entry state', async () => {
const { result } = setup()
await act(async () => {
await result.current.handleRestoreNote('/vault/note/test.md')
})
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_trashed', { silent: true })
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_trashed_at', { silent: true })
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
trashed: false,
trashedAt: null,
})
expect(setToastMessage).toHaveBeenCalledWith('Note restored from trash')
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
})
describe('handleArchiveNote', () => {
it('sets archived frontmatter and updates entry state', async () => {
const { result } = setup()
@@ -431,28 +377,6 @@ describe('useEntryActions', () => {
})
describe('optimistic rollback on disk write failure', () => {
it('rolls back trashed state when frontmatter write fails', async () => {
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = setup()
await act(async () => {
await result.current.handleTrashNote('/vault/note/test.md')
})
// First call: optimistic update (trashed: true)
// Second call: rollback (trashed: false)
expect(updateEntry).toHaveBeenCalledTimes(2)
expect(updateEntry).toHaveBeenNthCalledWith(1, '/vault/note/test.md', {
trashed: true, trashedAt: expect.any(Number),
})
expect(updateEntry).toHaveBeenNthCalledWith(2, '/vault/note/test.md', {
trashed: false, trashedAt: null,
})
expect(setToastMessage).toHaveBeenCalledWith('Failed to trash note — rolled back')
errorSpy.mockRestore()
})
it('rolls back archived state when frontmatter write fails', async () => {
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -469,24 +393,6 @@ describe('useEntryActions', () => {
errorSpy.mockRestore()
})
it('rolls back restore state when frontmatter write fails', async () => {
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = setup()
await act(async () => {
await result.current.handleRestoreNote('/vault/note/test.md')
})
expect(updateEntry).toHaveBeenCalledTimes(2)
expect(updateEntry).toHaveBeenNthCalledWith(1, '/vault/note/test.md', { trashed: false, trashedAt: null })
expect(updateEntry).toHaveBeenNthCalledWith(2, '/vault/note/test.md', {
trashed: true, trashedAt: expect.any(Number),
})
expect(setToastMessage).toHaveBeenCalledWith('Failed to restore note — rolled back')
errorSpy.mockRestore()
})
it('rolls back unarchive state when frontmatter write fails', async () => {
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -503,22 +409,6 @@ describe('useEntryActions', () => {
errorSpy.mockRestore()
})
it('trash: updateEntry is called BEFORE frontmatter writes (optimistic)', async () => {
const callOrder: string[] = []
updateEntry.mockImplementation(() => { callOrder.push('updateEntry') })
handleUpdateFrontmatter.mockImplementation(() => {
callOrder.push('handleUpdateFrontmatter')
return Promise.resolve()
})
const { result } = setup()
await act(async () => {
await result.current.handleTrashNote('/vault/note/test.md')
})
expect(callOrder[0]).toBe('updateEntry')
expect(callOrder[1]).toBe('handleUpdateFrontmatter')
})
})
describe('handleToggleFavorite', () => {
@@ -619,27 +509,6 @@ describe('useEntryActions', () => {
)
}
it('calls onBeforeAction before trashing a note', async () => {
const callOrder: string[] = []
const onBeforeAction = vi.fn().mockImplementation(() => {
callOrder.push('beforeAction')
return Promise.resolve()
})
handleUpdateFrontmatter.mockImplementation(() => {
callOrder.push('updateFrontmatter')
return Promise.resolve()
})
const { result } = setupWithBeforeAction(onBeforeAction)
await act(async () => {
await result.current.handleTrashNote('/vault/note/test.md')
})
expect(onBeforeAction).toHaveBeenCalledWith('/vault/note/test.md')
expect(callOrder[0]).toBe('beforeAction')
expect(callOrder[1]).toBe('updateFrontmatter')
})
it('calls onBeforeAction before archiving a note', async () => {
const onBeforeAction = vi.fn().mockResolvedValue(undefined)
const { result } = setupWithBeforeAction(onBeforeAction)
@@ -652,7 +521,6 @@ describe('useEntryActions', () => {
})
it.each([
['trash', 'handleTrashNote'] as const,
['archive', 'handleArchiveNote'] as const,
])('does not proceed with %s when onBeforeAction rejects', async (_label, method) => {
const { result } = setupWithBeforeAction(vi.fn().mockRejectedValue(new Error('Save failed')))

View File

@@ -28,40 +28,6 @@ async function findOrCreateType(
export function useEntryActions({
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted, onBeforeAction,
}: EntryActionsConfig) {
const handleTrashNote = useCallback(async (path: string) => {
await onBeforeAction?.(path)
// Optimistic: update UI immediately, write to disk async with rollback on failure
const trashedAt = Date.now() / 1000
updateEntry(path, { trashed: true, trashedAt })
trackEvent('note_trashed')
setToastMessage('Note moved to trash')
const now = new Date().toISOString().slice(0, 10)
try {
await handleUpdateFrontmatter(path, '_trashed', true, { silent: true })
await handleUpdateFrontmatter(path, '_trashed_at', now, { silent: true })
onFrontmatterPersisted?.()
} catch (err) {
updateEntry(path, { trashed: false, trashedAt: null })
setToastMessage('Failed to trash note — rolled back')
console.error('Optimistic trash rollback:', err)
}
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleRestoreNote = useCallback(async (path: string) => {
// Optimistic: update UI immediately
updateEntry(path, { trashed: false, trashedAt: null })
setToastMessage('Note restored from trash')
try {
await handleDeleteProperty(path, '_trashed', { silent: true })
await handleDeleteProperty(path, '_trashed_at', { silent: true })
onFrontmatterPersisted?.()
} catch (err) {
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
setToastMessage('Failed to restore note — rolled back')
console.error('Optimistic restore rollback:', err)
}
}, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleArchiveNote = useCallback(async (path: string) => {
await onBeforeAction?.(path)
// Optimistic: update UI immediately, write to disk async with rollback on failure
@@ -204,5 +170,5 @@ export function useEntryActions({
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility, handleToggleFavorite, handleToggleOrganized, handleReorderFavorites }
return { handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility, handleToggleFavorite, handleToggleOrganized, handleReorderFavorites }
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { VaultEntry, SidebarSelection } from '../types'
import type { VaultEntry } from '../types'
import { useKeyboardNavigation } from './useKeyboardNavigation'
vi.mock('../mock-tauri', () => ({
@@ -17,8 +17,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
relatedTo: [],
status: 'Active',
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
@@ -44,13 +42,11 @@ describe('useKeyboardNavigation', () => {
makeEntry({ path: '/vault/c.md', title: 'C', modifiedAt: 1700000001 }),
]
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = []
beforeEach(() => {
vi.clearAllMocks()
addedListeners = []
// Track added listeners for cleanup verification
const origAdd = window.addEventListener
const origRemove = window.removeEventListener
vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: EventListenerOrEventListenerObject, opts?: boolean | AddEventListenerOptions) => {
@@ -66,24 +62,25 @@ describe('useKeyboardNavigation', () => {
vi.restoreAllMocks()
})
it('registers keydown listener on mount', () => {
renderHook(() =>
function renderNav(activeTabPath: string | null, noteList: VaultEntry[] = entries) {
const visibleNotesRef = { current: noteList }
return renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
activeTabPath,
visibleNotesRef,
onReplaceActiveTab,
onSelectNote,
})
)
}
it('registers keydown listener on mount', () => {
renderNav('/vault/a.md')
expect(addedListeners.some(l => l.type === 'keydown')).toBe(true)
})
it('navigates to next note on Cmd+Alt+ArrowDown', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -91,16 +88,11 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onReplaceActiveTab).toHaveBeenCalled()
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[1])
})
it('navigates to previous note on Cmd+Alt+ArrowUp', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/b.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/b.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -108,16 +100,11 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onReplaceActiveTab).toHaveBeenCalled()
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[0])
})
it('selects first note when no active tab', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: null, entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
it('does not wrap when at the last note and pressing Down', () => {
renderNav('/vault/c.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -125,16 +112,49 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onSelectNote).toHaveBeenCalled()
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('does not wrap when at the first note and pressing Up', () => {
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowUp', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('selects first note when no active tab and pressing Down', () => {
renderNav(null)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onSelectNote).toHaveBeenCalledWith(entries[0])
})
it('selects last note when no active tab and pressing Up', () => {
renderNav(null)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowUp', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onSelectNote).toHaveBeenCalledWith(entries[2])
})
it('does nothing without modifier keys', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -145,4 +165,32 @@ describe('useKeyboardNavigation', () => {
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('navigates in the order provided by visibleNotesRef (not by modifiedAt)', () => {
// Provide notes in reverse-alpha order (C, B, A) regardless of modifiedAt
const customOrder = [entries[2], entries[1], entries[0]]
renderNav('/vault/c.md', customOrder)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
// Should navigate to B (next in custom order), not based on modifiedAt
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[1])
})
it('does nothing when note list is empty', () => {
renderNav('/vault/a.md', [])
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
})

View File

@@ -1,26 +1,13 @@
import { useEffect, useMemo, useRef } from 'react'
import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
import { useEffect, useRef } from 'react'
import type { VaultEntry } from '../types'
interface KeyboardNavigationOptions {
activeTabPath: string | null
entries: VaultEntry[]
selection: SidebarSelection
visibleNotesRef: React.RefObject<VaultEntry[]>
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
}
function computeVisibleNotes(
entries: VaultEntry[],
selection: SidebarSelection,
): VaultEntry[] {
if (selection.kind === 'entity') {
return buildRelationshipGroups(selection.entry, entries)
.flatMap((g) => g.entries)
}
return [...filterEntries(entries, selection)].sort(sortByModified)
}
function navigateNote(
visibleNotesRef: React.RefObject<VaultEntry[]>,
activeTabPathRef: React.RefObject<string | null>,
@@ -36,7 +23,10 @@ function navigateNote(
const nextIndex = currentIndex === -1
? (direction === 1 ? 0 : notes.length - 1)
: (currentIndex + direction + notes.length) % notes.length
: currentIndex + direction
// Clamp to list bounds — don't wrap around
if (nextIndex < 0 || nextIndex >= notes.length) return
const nextNote = notes[nextIndex]
if (currentPath) {
@@ -53,16 +43,10 @@ function useLatestRef<T>(value: T): React.RefObject<T> {
}
export function useKeyboardNavigation({
activeTabPath, entries, selection,
activeTabPath, visibleNotesRef,
onReplaceActiveTab, onSelectNote,
}: KeyboardNavigationOptions) {
const visibleNotes = useMemo(
() => computeVisibleNotes(entries, selection),
[entries, selection],
)
const activeTabPathRef = useLatestRef(activeTabPath)
const visibleNotesRef = useLatestRef(visibleNotes)
const onReplaceRef = useLatestRef(onReplaceActiveTab)
const onSelectNoteRef = useLatestRef(onSelectNote)

View File

@@ -16,7 +16,7 @@ function makeHandlers(): MenuEventHandlers {
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
onArchiveNote: vi.fn(),
onTrashNote: vi.fn(),
onDeleteNote: vi.fn(),
onSearch: vi.fn(),
onToggleRawEditor: vi.fn(),
onToggleDiff: vi.fn(),
@@ -141,17 +141,17 @@ describe('dispatchMenuEvent', () => {
expect(h.onArchiveNote).not.toHaveBeenCalled()
})
it('note-trash triggers trash on active tab', () => {
it('note-delete triggers delete on active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('note-trash', h)
expect(h.onTrashNote).toHaveBeenCalledWith('/vault/test.md')
dispatchMenuEvent('note-delete', h)
expect(h.onDeleteNote).toHaveBeenCalledWith('/vault/test.md')
})
it('note-trash does nothing when no active tab', () => {
it('note-delete does nothing when no active tab', () => {
const h = makeHandlers()
h.activeTabPathRef = { current: null }
dispatchMenuEvent('note-trash', h)
expect(h.onTrashNote).not.toHaveBeenCalled()
dispatchMenuEvent('note-delete', h)
expect(h.onDeleteNote).not.toHaveBeenCalled()
})
// Optional handler events
@@ -218,12 +218,6 @@ describe('dispatchMenuEvent', () => {
expect(h.onSelectFilter).toHaveBeenCalledWith('archived')
})
it('go-trash selects trash filter', () => {
const h = makeHandlers()
dispatchMenuEvent('go-trash', h)
expect(h.onSelectFilter).toHaveBeenCalledWith('trash')
})
it('go-changes selects changes filter', () => {
const h = makeHandlers()
dispatchMenuEvent('go-changes', h)

View File

@@ -17,7 +17,7 @@ export interface MenuEventHandlers {
onZoomOut: () => void
onZoomReset: () => void
onArchiveNote: (path: string) => void
onTrashNote: (path: string) => void
onDeleteNote: (path: string) => void
onSearch: () => void
onToggleRawEditor?: () => void
onToggleDiff?: () => void
@@ -37,7 +37,6 @@ export interface MenuEventHandlers {
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onEmptyTrash?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
activeTabPath: string | null
modifiedCount?: number
@@ -70,7 +69,6 @@ const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
const FILTER_MAP: Record<string, SidebarFilter> = {
'go-all-notes': 'all',
'go-archived': 'archived',
'go-trash': 'trash',
'go-changes': 'changes',
'go-inbox': 'inbox',
}
@@ -80,7 +78,6 @@ type OptionalHandler =
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'
| 'onEmptyTrash'
| 'onOpenInNewWindow'
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
@@ -101,15 +98,14 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-install-mcp': 'onInstallMcp',
'vault-reload': 'onReloadVault',
'vault-repair': 'onRepairVault',
'note-empty-trash': 'onEmptyTrash',
'note-open-in-new-window': 'onOpenInNewWindow',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
const path = h.activeTabPathRef.current
if (!path) return id === 'note-archive' || id === 'note-trash'
if (!path) return id === 'note-archive' || id === 'note-delete'
if (id === 'note-archive') { h.onArchiveNote(path); return true }
if (id === 'note-trash') { h.onTrashNote(path); return true }
if (id === 'note-delete') { h.onDeleteNote(path); return true }
return false
}

Some files were not shown because too many files have changed in this diff Show More