Compare commits
55 Commits
v0.2026030
...
v0.2026031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8137125569 | ||
|
|
9891a29f7f | ||
|
|
6c9b39c0f0 | ||
|
|
3207b0b10e | ||
|
|
088c495520 | ||
|
|
70f984c399 | ||
|
|
0d6cce1588 | ||
|
|
73278f3baf | ||
|
|
93dc454a8a | ||
|
|
1b7b7f3fde | ||
|
|
7e20a36469 | ||
|
|
61a145c49b | ||
|
|
9a7369c799 | ||
|
|
13d3b2d375 | ||
|
|
9994f2386c | ||
|
|
a496a115bf | ||
|
|
ed4926d59f | ||
|
|
c158cbccff | ||
|
|
12416b99bc | ||
|
|
a25f9ee1fc | ||
|
|
8a48c21445 | ||
|
|
7c72494efb | ||
|
|
10b4e6d038 | ||
|
|
55519e53ad | ||
|
|
fafa4e394b | ||
|
|
ab02aa5e96 | ||
|
|
4dd27cf0c3 | ||
|
|
c499ef30f0 | ||
|
|
b3bf2bf76e | ||
|
|
13622bc236 | ||
|
|
80ad5cfad7 | ||
|
|
068d70c264 | ||
|
|
86ffb43eb7 | ||
|
|
3504bb221a | ||
|
|
f2136f17ef | ||
|
|
75ca18d4d0 | ||
|
|
47c408dc50 | ||
|
|
96d7df368a | ||
|
|
c7b0c15537 | ||
|
|
0ee6e76d10 | ||
|
|
25c44910d1 | ||
|
|
61760c4a41 | ||
|
|
8104a8380c | ||
|
|
593a0d3d54 | ||
|
|
a50aae70e8 | ||
|
|
2387b9a637 | ||
|
|
27452515d7 | ||
|
|
14a4d371e6 | ||
|
|
9199ceaa35 | ||
|
|
6af18655de | ||
|
|
90bf73524c | ||
|
|
c1f7f7ec6f | ||
|
|
d1021b9131 | ||
|
|
b9139e2d57 | ||
|
|
c692c5d067 |
1
design/trash-management.pen
Normal file
1
design/trash-management.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -416,6 +416,10 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps.
|
||||
|
||||
## Closed Tab History
|
||||
|
||||
`useClosedTabHistory` hook (`src/hooks/useClosedTabHistory.ts`) provides a LIFO stack for closed tab entries, used by `useTabManagement` to support Cmd+Shift+T reopen. Each entry stores the note's path, tab index, and full `VaultEntry`. The stack is in-memory only (resets on restart), capped at 20 entries, and deduplicates by path.
|
||||
|
||||
## Search & Indexing
|
||||
|
||||
### Search Modes
|
||||
|
||||
@@ -520,7 +520,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
## Tauri IPC Commands (62 total)
|
||||
## Tauri IPC Commands (64 total)
|
||||
|
||||
### Vault Operations
|
||||
|
||||
@@ -533,6 +533,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename_note` | Rename note + update cross-vault wikilinks |
|
||||
| `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` |
|
||||
@@ -649,7 +651,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
|
||||
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations |
|
||||
| `useTabManagement` | Tab ordering, pinning, swapping | Tab lifecycle |
|
||||
| `useTabManagement` | Tab ordering, pinning, swapping, closed-tab history | Tab lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state |
|
||||
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
|
||||
@@ -670,6 +672,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+N | Create new note |
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+W | Close active tab |
|
||||
| Cmd+Shift+T | Reopen last closed tab (LIFO history, up to 20) |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
},
|
||||
|
||||
18
playwright.integration.config.ts
Normal file
18
playwright.integration.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/integration',
|
||||
timeout: 30_000,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5365',
|
||||
headless: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5365'}`,
|
||||
url: process.env.BASE_URL || 'http://localhost:5365',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
@@ -84,10 +84,11 @@ pub fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
old_title: Option<String>,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -113,6 +114,18 @@ pub fn delete_note(path: String) -> Result<String, String> {
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
|
||||
let expanded: Vec<String> = paths.iter().map(|p| expand_tilde(p).into_owned()).collect();
|
||||
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);
|
||||
|
||||
@@ -143,6 +143,8 @@ pub fn run() {
|
||||
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::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
|
||||
@@ -13,6 +13,7 @@ const FILE_DAILY_NOTE: &str = "file-daily-note";
|
||||
const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
const FILE_CLOSE_TAB: &str = "file-close-tab";
|
||||
const FILE_REOPEN_CLOSED_TAB: &str = "file-reopen-closed-tab";
|
||||
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
|
||||
@@ -38,6 +39,7 @@ const GO_CHANGES: &str = "go-changes";
|
||||
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const NOTE_EMPTY_TRASH: &str = "note-empty-trash";
|
||||
|
||||
const VAULT_OPEN: &str = "vault-open";
|
||||
const VAULT_REMOVE: &str = "vault-remove";
|
||||
@@ -61,6 +63,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
FILE_CLOSE_TAB,
|
||||
FILE_REOPEN_CLOSED_TAB,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
@@ -82,6 +85,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
@@ -165,6 +169,10 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_CLOSE_TAB)
|
||||
.accelerator("CmdOrCtrl+W")
|
||||
.build(app)?;
|
||||
let reopen_closed_tab = MenuItemBuilder::new("Reopen Closed Tab")
|
||||
.id(FILE_REOPEN_CLOSED_TAB)
|
||||
.accelerator("CmdOrCtrl+Shift+T")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "File")
|
||||
.item(&new_note)
|
||||
@@ -174,6 +182,7 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.item(&save)
|
||||
.item(&close_tab)
|
||||
.item(&reopen_closed_tab)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
@@ -287,6 +296,9 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.id(NOTE_TRASH)
|
||||
.accelerator("CmdOrCtrl+Backspace")
|
||||
.build(app)?;
|
||||
let empty_trash = MenuItemBuilder::new("Empty Trash…")
|
||||
.id(NOTE_EMPTY_TRASH)
|
||||
.build(app)?;
|
||||
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
|
||||
.id(EDIT_TOGGLE_RAW_EDITOR)
|
||||
.accelerator("CmdOrCtrl+\\")
|
||||
@@ -302,6 +314,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&archive_note)
|
||||
.item(&trash_note)
|
||||
.item(&empty_trash)
|
||||
.separator()
|
||||
.item(&toggle_raw_editor)
|
||||
.item(&toggle_ai_chat)
|
||||
@@ -471,6 +484,7 @@ mod tests {
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
|
||||
@@ -93,7 +93,8 @@ fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
|
||||
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
|
||||
for (key, value) in vars {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(')
|
||||
{
|
||||
fm.push_str(&format!("{key}: \"{value}\"\n"));
|
||||
} else {
|
||||
fm.push_str(&format!("{key}: {value}\n"));
|
||||
@@ -216,4 +217,85 @@ mod tests {
|
||||
assert_eq!(slugify("default"), "default");
|
||||
assert_eq!(slugify("Dark Mode!"), "dark-mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_contains_all_default_css_vars() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let path = create_vault_theme(vp, Some("Full Theme")).unwrap();
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
|
||||
// Every entry in DEFAULT_VAULT_THEME_VARS must appear in the generated file
|
||||
for (key, _) in &DEFAULT_VAULT_THEME_VARS {
|
||||
assert!(
|
||||
content.contains(&format!("{key}:")),
|
||||
"missing key in theme file: {key}"
|
||||
);
|
||||
}
|
||||
|
||||
// Spot-check editor properties from theme.json that were previously missing
|
||||
assert!(
|
||||
content.contains("editor-font-family:"),
|
||||
"missing editor-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("editor-padding-horizontal:"),
|
||||
"missing editor-padding-horizontal"
|
||||
);
|
||||
assert!(
|
||||
content.contains("headings-h1-font-size:"),
|
||||
"missing headings-h1-font-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("lists-bullet-size:"),
|
||||
"missing lists-bullet-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("lists-bullet-color:"),
|
||||
"missing lists-bullet-color"
|
||||
);
|
||||
assert!(
|
||||
content.contains("checkboxes-size:"),
|
||||
"missing checkboxes-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("inline-styles-bold-font-weight:"),
|
||||
"missing inline-styles-bold-font-weight"
|
||||
);
|
||||
assert!(
|
||||
content.contains("code-blocks-font-family:"),
|
||||
"missing code-blocks-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("blockquote-border-left-width:"),
|
||||
"missing blockquote-border-left-width"
|
||||
);
|
||||
assert!(
|
||||
content.contains("table-border-color:"),
|
||||
"missing table-border-color"
|
||||
);
|
||||
assert!(
|
||||
content.contains("horizontal-rule-thickness:"),
|
||||
"missing horizontal-rule-thickness"
|
||||
);
|
||||
assert!(content.contains("colors-text:"), "missing colors-text");
|
||||
assert!(content.contains("colors-cursor:"), "missing colors-cursor");
|
||||
|
||||
// Numeric values that need CSS units must have px suffix
|
||||
assert!(
|
||||
content.contains("editor-font-size: 15px"),
|
||||
"editor-font-size should have px unit"
|
||||
);
|
||||
assert!(
|
||||
content.contains("editor-max-width: 720px"),
|
||||
"editor-max-width should have px unit"
|
||||
);
|
||||
assert!(
|
||||
content.contains("editor-padding-horizontal: 40px"),
|
||||
"editor-padding-horizontal should have px unit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,16 @@ pub const MINIMAL_THEME: &str = r##"{
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// CSS variable key-value pairs for the default light vault theme.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
// shadcn/ui base
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault-based theme notes (markdown with frontmatter CSS custom properties)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Complete set of CSS variable key-value pairs for the default light vault theme.
|
||||
/// Includes both UI chrome colours and all editor styling properties from theme.json.
|
||||
/// Numeric values that need CSS units include the `px` suffix; unitless values
|
||||
/// (line-height, font-weight) are bare numbers.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 140] = [
|
||||
// ── shadcn/ui base colours ──────────────────────────────────────────
|
||||
("background", "#FFFFFF"),
|
||||
("foreground", "#37352F"),
|
||||
("card", "#FFFFFF"),
|
||||
@@ -126,19 +133,21 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
("sidebar-foreground", "#37352F"),
|
||||
("sidebar-border", "#E9E9E7"),
|
||||
("sidebar-accent", "#EBEBEA"),
|
||||
// Text hierarchy
|
||||
// ── Text hierarchy ──────────────────────────────────────────────────
|
||||
("text-primary", "#37352F"),
|
||||
("text-secondary", "#787774"),
|
||||
("text-tertiary", "#B4B4B4"),
|
||||
("text-muted", "#B4B4B4"),
|
||||
("text-heading", "#37352F"),
|
||||
// Backgrounds
|
||||
// ── Backgrounds ─────────────────────────────────────────────────────
|
||||
("bg-primary", "#FFFFFF"),
|
||||
("bg-card", "#FFFFFF"),
|
||||
("bg-sidebar", "#F7F6F3"),
|
||||
("bg-hover", "#EBEBEA"),
|
||||
("bg-hover-subtle", "#F0F0EF"),
|
||||
("bg-selected", "#E8F4FE"),
|
||||
("border-primary", "#E9E9E7"),
|
||||
// Accent colours
|
||||
// ── Accent colours ──────────────────────────────────────────────────
|
||||
("accent-blue", "#155DFF"),
|
||||
("accent-green", "#00B38B"),
|
||||
("accent-orange", "#D9730D"),
|
||||
@@ -150,185 +159,286 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
("accent-purple-light", "#A932FF14"),
|
||||
("accent-red-light", "#E03E3E14"),
|
||||
("accent-yellow-light", "#F0B10014"),
|
||||
// Typography
|
||||
// ── Typography base ─────────────────────────────────────────────────
|
||||
(
|
||||
"font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("font-size-base", "14px"),
|
||||
// Editor
|
||||
("editor-font-size", "16"),
|
||||
// ── Editor (from theme.json → editor) ───────────────────────────────
|
||||
(
|
||||
"editor-font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("editor-font-size", "15px"),
|
||||
("editor-line-height", "1.5"),
|
||||
("editor-max-width", "720"),
|
||||
("editor-max-width", "720px"),
|
||||
("editor-padding-horizontal", "40px"),
|
||||
("editor-padding-vertical", "20px"),
|
||||
("editor-paragraph-spacing", "8px"),
|
||||
// ── Headings H1 ────────────────────────────────────────────────────
|
||||
("headings-h1-font-size", "32px"),
|
||||
("headings-h1-font-weight", "700"),
|
||||
("headings-h1-line-height", "1.2"),
|
||||
("headings-h1-margin-top", "32px"),
|
||||
("headings-h1-margin-bottom", "12px"),
|
||||
("headings-h1-color", "var(--text-heading)"),
|
||||
("headings-h1-letter-spacing", "-0.5px"),
|
||||
// ── Headings H2 ────────────────────────────────────────────────────
|
||||
("headings-h2-font-size", "27px"),
|
||||
("headings-h2-font-weight", "600"),
|
||||
("headings-h2-line-height", "1.4"),
|
||||
("headings-h2-margin-top", "28px"),
|
||||
("headings-h2-margin-bottom", "10px"),
|
||||
("headings-h2-color", "var(--text-heading)"),
|
||||
("headings-h2-letter-spacing", "-0.5px"),
|
||||
// ── Headings H3 ────────────────────────────────────────────────────
|
||||
("headings-h3-font-size", "20px"),
|
||||
("headings-h3-font-weight", "600"),
|
||||
("headings-h3-line-height", "1.4"),
|
||||
("headings-h3-margin-top", "24px"),
|
||||
("headings-h3-margin-bottom", "8px"),
|
||||
("headings-h3-color", "var(--text-heading)"),
|
||||
("headings-h3-letter-spacing", "-0.5px"),
|
||||
// ── Headings H4 ────────────────────────────────────────────────────
|
||||
("headings-h4-font-size", "20px"),
|
||||
("headings-h4-font-weight", "600"),
|
||||
("headings-h4-line-height", "1.4"),
|
||||
("headings-h4-margin-top", "20px"),
|
||||
("headings-h4-margin-bottom", "6px"),
|
||||
("headings-h4-color", "var(--text-heading)"),
|
||||
("headings-h4-letter-spacing", "0px"),
|
||||
// ── Lists ───────────────────────────────────────────────────────────
|
||||
("lists-bullet-size", "28px"),
|
||||
("lists-bullet-color", "#177bfd"),
|
||||
("lists-indent-size", "24px"),
|
||||
("lists-item-spacing", "4px"),
|
||||
("lists-padding-left", "8px"),
|
||||
("lists-bullet-gap", "6px"),
|
||||
// ── Checkboxes ──────────────────────────────────────────────────────
|
||||
("checkboxes-size", "18px"),
|
||||
("checkboxes-border-radius", "3px"),
|
||||
("checkboxes-checked-color", "var(--accent-blue)"),
|
||||
("checkboxes-unchecked-border-color", "var(--text-muted)"),
|
||||
("checkboxes-gap", "8px"),
|
||||
// ── Inline styles: bold ─────────────────────────────────────────────
|
||||
("inline-styles-bold-font-weight", "700"),
|
||||
("inline-styles-bold-color", "var(--text-primary)"),
|
||||
// ── Inline styles: italic ───────────────────────────────────────────
|
||||
("inline-styles-italic-font-style", "italic"),
|
||||
("inline-styles-italic-color", "var(--text-primary)"),
|
||||
// ── Inline styles: strikethrough ────────────────────────────────────
|
||||
("inline-styles-strikethrough-color", "var(--text-tertiary)"),
|
||||
(
|
||||
"inline-styles-strikethrough-text-decoration",
|
||||
"line-through",
|
||||
),
|
||||
// ── Inline styles: code ─────────────────────────────────────────────
|
||||
(
|
||||
"inline-styles-code-font-family",
|
||||
"'SF Mono', 'Fira Code', monospace",
|
||||
),
|
||||
("inline-styles-code-font-size", "14px"),
|
||||
(
|
||||
"inline-styles-code-background-color",
|
||||
"var(--bg-hover-subtle)",
|
||||
),
|
||||
("inline-styles-code-padding-horizontal", "4px"),
|
||||
("inline-styles-code-padding-vertical", "2px"),
|
||||
("inline-styles-code-border-radius", "3px"),
|
||||
("inline-styles-code-color", "var(--text-secondary)"),
|
||||
// ── Inline styles: link ─────────────────────────────────────────────
|
||||
("inline-styles-link-color", "var(--accent-blue)"),
|
||||
("inline-styles-link-text-decoration", "underline"),
|
||||
// ── Inline styles: wikilink ─────────────────────────────────────────
|
||||
("inline-styles-wikilink-color", "var(--accent-blue)"),
|
||||
("inline-styles-wikilink-text-decoration", "none"),
|
||||
(
|
||||
"inline-styles-wikilink-border-bottom",
|
||||
"1px dotted currentColor",
|
||||
),
|
||||
("inline-styles-wikilink-cursor", "pointer"),
|
||||
// ── Code blocks ─────────────────────────────────────────────────────
|
||||
(
|
||||
"code-blocks-font-family",
|
||||
"'SF Mono', 'Fira Code', monospace",
|
||||
),
|
||||
("code-blocks-font-size", "13px"),
|
||||
("code-blocks-line-height", "1.5"),
|
||||
("code-blocks-background-color", "var(--bg-card)"),
|
||||
("code-blocks-padding-horizontal", "16px"),
|
||||
("code-blocks-padding-vertical", "12px"),
|
||||
("code-blocks-border-radius", "6px"),
|
||||
("code-blocks-margin-vertical", "12px"),
|
||||
// ── Blockquote ──────────────────────────────────────────────────────
|
||||
("blockquote-border-left-width", "3px"),
|
||||
("blockquote-border-left-color", "var(--accent-blue)"),
|
||||
("blockquote-padding-left", "16px"),
|
||||
("blockquote-margin-vertical", "12px"),
|
||||
("blockquote-color", "var(--text-secondary)"),
|
||||
("blockquote-font-style", "italic"),
|
||||
// ── Table ───────────────────────────────────────────────────────────
|
||||
("table-border-color", "var(--border-primary)"),
|
||||
("table-header-background", "var(--bg-card)"),
|
||||
("table-cell-padding-horizontal", "12px"),
|
||||
("table-cell-padding-vertical", "8px"),
|
||||
("table-font-size", "14px"),
|
||||
// ── Horizontal rule ─────────────────────────────────────────────────
|
||||
("horizontal-rule-color", "var(--border-primary)"),
|
||||
("horizontal-rule-margin-vertical", "24px"),
|
||||
("horizontal-rule-thickness", "1px"),
|
||||
// ── Colors (semantic aliases from theme.json → colors) ──────────────
|
||||
("colors-background", "var(--bg-primary)"),
|
||||
("colors-text", "var(--text-primary)"),
|
||||
("colors-text-secondary", "var(--text-secondary)"),
|
||||
("colors-text-muted", "var(--text-muted)"),
|
||||
("colors-heading", "var(--text-heading)"),
|
||||
("colors-accent", "var(--accent-blue)"),
|
||||
("colors-selection", "var(--bg-selected)"),
|
||||
("colors-cursor", "var(--text-primary)"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Default theme.
|
||||
pub const DEFAULT_VAULT_THEME: &str = "---\n\
|
||||
type: Theme\n\
|
||||
Description: Light theme with warm, paper-like tones\n\
|
||||
background: \"#FFFFFF\"\n\
|
||||
foreground: \"#37352F\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#EBEBEA\"\n\
|
||||
secondary-foreground: \"#37352F\"\n\
|
||||
muted: \"#F0F0EF\"\n\
|
||||
muted-foreground: \"#787774\"\n\
|
||||
accent: \"#EBEBEA\"\n\
|
||||
accent-foreground: \"#37352F\"\n\
|
||||
destructive: \"#E03E3E\"\n\
|
||||
border: \"#E9E9E7\"\n\
|
||||
input: \"#E9E9E7\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#F7F6F3\"\n\
|
||||
sidebar-foreground: \"#37352F\"\n\
|
||||
sidebar-border: \"#E9E9E7\"\n\
|
||||
sidebar-accent: \"#EBEBEA\"\n\
|
||||
text-primary: \"#37352F\"\n\
|
||||
text-secondary: \"#787774\"\n\
|
||||
text-muted: \"#B4B4B4\"\n\
|
||||
text-heading: \"#37352F\"\n\
|
||||
bg-primary: \"#FFFFFF\"\n\
|
||||
bg-sidebar: \"#F7F6F3\"\n\
|
||||
bg-hover: \"#EBEBEA\"\n\
|
||||
bg-hover-subtle: \"#F0F0EF\"\n\
|
||||
bg-selected: \"#E8F4FE\"\n\
|
||||
border-primary: \"#E9E9E7\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#E03E3E\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF14\"\n\
|
||||
accent-green-light: \"#00B38B14\"\n\
|
||||
accent-purple-light: \"#A932FF14\"\n\
|
||||
accent-red-light: \"#E03E3E14\"\n\
|
||||
accent-yellow-light: \"#F0B10014\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Default Theme\n\
|
||||
\n\
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
|
||||
/// UI-colour overrides for the Dark vault theme (keys that differ from default).
|
||||
const DARK_COLOR_OVERRIDES: &[(&str, &str)] = &[
|
||||
("background", "#0f0f1a"),
|
||||
("foreground", "#e0e0e0"),
|
||||
("card", "#16162a"),
|
||||
("popover", "#1e1e3a"),
|
||||
("secondary", "#2a2a4a"),
|
||||
("secondary-foreground", "#e0e0e0"),
|
||||
("muted", "#1e1e3a"),
|
||||
("muted-foreground", "#888888"),
|
||||
("accent", "#2a2a4a"),
|
||||
("accent-foreground", "#e0e0e0"),
|
||||
("destructive", "#f44336"),
|
||||
("border", "#2a2a4a"),
|
||||
("input", "#2a2a4a"),
|
||||
("sidebar", "#1a1a2e"),
|
||||
("sidebar-foreground", "#e0e0e0"),
|
||||
("sidebar-border", "#2a2a4a"),
|
||||
("sidebar-accent", "#2a2a4a"),
|
||||
("text-primary", "#e0e0e0"),
|
||||
("text-secondary", "#888888"),
|
||||
("text-tertiary", "#666666"),
|
||||
("text-muted", "#666666"),
|
||||
("text-heading", "#e0e0e0"),
|
||||
("bg-primary", "#0f0f1a"),
|
||||
("bg-card", "#16162a"),
|
||||
("bg-sidebar", "#1a1a2e"),
|
||||
("bg-hover", "#2a2a4a"),
|
||||
("bg-hover-subtle", "#1e1e3a"),
|
||||
("bg-selected", "#155DFF22"),
|
||||
("border-primary", "#2a2a4a"),
|
||||
("accent-red", "#f44336"),
|
||||
("accent-blue-light", "#155DFF33"),
|
||||
("accent-green-light", "#00B38B33"),
|
||||
("accent-purple-light", "#A932FF33"),
|
||||
("accent-red-light", "#f4433633"),
|
||||
("accent-yellow-light", "#F0B10033"),
|
||||
("lists-bullet-color", "#155DFF"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Dark theme.
|
||||
pub const DARK_VAULT_THEME: &str = "---\n\
|
||||
type: Theme\n\
|
||||
Description: Dark variant with deep navy tones\n\
|
||||
background: \"#0f0f1a\"\n\
|
||||
foreground: \"#e0e0e0\"\n\
|
||||
card: \"#16162a\"\n\
|
||||
popover: \"#1e1e3a\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#2a2a4a\"\n\
|
||||
secondary-foreground: \"#e0e0e0\"\n\
|
||||
muted: \"#1e1e3a\"\n\
|
||||
muted-foreground: \"#888888\"\n\
|
||||
accent: \"#2a2a4a\"\n\
|
||||
accent-foreground: \"#e0e0e0\"\n\
|
||||
destructive: \"#f44336\"\n\
|
||||
border: \"#2a2a4a\"\n\
|
||||
input: \"#2a2a4a\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#1a1a2e\"\n\
|
||||
sidebar-foreground: \"#e0e0e0\"\n\
|
||||
sidebar-border: \"#2a2a4a\"\n\
|
||||
sidebar-accent: \"#2a2a4a\"\n\
|
||||
text-primary: \"#e0e0e0\"\n\
|
||||
text-secondary: \"#888888\"\n\
|
||||
text-muted: \"#666666\"\n\
|
||||
text-heading: \"#e0e0e0\"\n\
|
||||
bg-primary: \"#0f0f1a\"\n\
|
||||
bg-sidebar: \"#1a1a2e\"\n\
|
||||
bg-hover: \"#2a2a4a\"\n\
|
||||
bg-hover-subtle: \"#1e1e3a\"\n\
|
||||
bg-selected: \"#155DFF22\"\n\
|
||||
border-primary: \"#2a2a4a\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#f44336\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF33\"\n\
|
||||
accent-green-light: \"#00B38B33\"\n\
|
||||
accent-purple-light: \"#A932FF33\"\n\
|
||||
accent-red-light: \"#f4433633\"\n\
|
||||
accent-yellow-light: \"#F0B10033\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Dark Theme\n\
|
||||
\n\
|
||||
A dark theme with deep navy tones for comfortable night-time reading.\n";
|
||||
/// UI-colour + editor-property overrides for the Minimal vault theme.
|
||||
const MINIMAL_OVERRIDES: &[(&str, &str)] = &[
|
||||
("background", "#FAFAFA"),
|
||||
("foreground", "#111111"),
|
||||
("primary", "#000000"),
|
||||
("secondary", "#F0F0F0"),
|
||||
("secondary-foreground", "#111111"),
|
||||
("muted", "#F5F5F5"),
|
||||
("muted-foreground", "#666666"),
|
||||
("accent", "#F0F0F0"),
|
||||
("accent-foreground", "#111111"),
|
||||
("destructive", "#CC0000"),
|
||||
("border", "#E0E0E0"),
|
||||
("input", "#E0E0E0"),
|
||||
("ring", "#000000"),
|
||||
("sidebar", "#F5F5F5"),
|
||||
("sidebar-foreground", "#111111"),
|
||||
("sidebar-border", "#E0E0E0"),
|
||||
("sidebar-accent", "#E8E8E8"),
|
||||
("text-primary", "#111111"),
|
||||
("text-secondary", "#666666"),
|
||||
("text-tertiary", "#999999"),
|
||||
("text-muted", "#999999"),
|
||||
("text-heading", "#111111"),
|
||||
("bg-primary", "#FAFAFA"),
|
||||
("bg-card", "#FFFFFF"),
|
||||
("bg-sidebar", "#F5F5F5"),
|
||||
("bg-hover", "#EBEBEB"),
|
||||
("bg-hover-subtle", "#F5F5F5"),
|
||||
("bg-selected", "#00000014"),
|
||||
("border-primary", "#E0E0E0"),
|
||||
("accent-blue", "#000000"),
|
||||
("accent-green", "#006600"),
|
||||
("accent-orange", "#996600"),
|
||||
("accent-red", "#CC0000"),
|
||||
("accent-purple", "#660099"),
|
||||
("accent-yellow", "#996600"),
|
||||
("accent-blue-light", "#00000014"),
|
||||
("accent-green-light", "#00660014"),
|
||||
("accent-purple-light", "#66009914"),
|
||||
("accent-red-light", "#CC000014"),
|
||||
("accent-yellow-light", "#99660014"),
|
||||
("font-family", "'SF Mono', 'Menlo', monospace"),
|
||||
("font-size-base", "13px"),
|
||||
("editor-font-size", "15px"),
|
||||
("editor-line-height", "1.6"),
|
||||
("editor-max-width", "680px"),
|
||||
("lists-bullet-color", "#000000"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Minimal theme.
|
||||
pub const MINIMAL_VAULT_THEME: &str = "---\n\
|
||||
type: Theme\n\
|
||||
Description: High contrast, minimal chrome\n\
|
||||
background: \"#FAFAFA\"\n\
|
||||
foreground: \"#111111\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#000000\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#F0F0F0\"\n\
|
||||
secondary-foreground: \"#111111\"\n\
|
||||
muted: \"#F5F5F5\"\n\
|
||||
muted-foreground: \"#666666\"\n\
|
||||
accent: \"#F0F0F0\"\n\
|
||||
accent-foreground: \"#111111\"\n\
|
||||
destructive: \"#CC0000\"\n\
|
||||
border: \"#E0E0E0\"\n\
|
||||
input: \"#E0E0E0\"\n\
|
||||
ring: \"#000000\"\n\
|
||||
sidebar: \"#F5F5F5\"\n\
|
||||
sidebar-foreground: \"#111111\"\n\
|
||||
sidebar-border: \"#E0E0E0\"\n\
|
||||
sidebar-accent: \"#E8E8E8\"\n\
|
||||
text-primary: \"#111111\"\n\
|
||||
text-secondary: \"#666666\"\n\
|
||||
text-muted: \"#999999\"\n\
|
||||
text-heading: \"#111111\"\n\
|
||||
bg-primary: \"#FAFAFA\"\n\
|
||||
bg-sidebar: \"#F5F5F5\"\n\
|
||||
bg-hover: \"#EBEBEB\"\n\
|
||||
bg-hover-subtle: \"#F5F5F5\"\n\
|
||||
bg-selected: \"#00000014\"\n\
|
||||
border-primary: \"#E0E0E0\"\n\
|
||||
accent-blue: \"#000000\"\n\
|
||||
accent-green: \"#006600\"\n\
|
||||
accent-orange: \"#996600\"\n\
|
||||
accent-red: \"#CC0000\"\n\
|
||||
accent-purple: \"#660099\"\n\
|
||||
accent-yellow: \"#996600\"\n\
|
||||
accent-blue-light: \"#00000014\"\n\
|
||||
accent-green-light: \"#00660014\"\n\
|
||||
accent-purple-light: \"#66009914\"\n\
|
||||
accent-red-light: \"#CC000014\"\n\
|
||||
accent-yellow-light: \"#99660014\"\n\
|
||||
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
|
||||
font-size-base: 13px\n\
|
||||
editor-font-size: 15\n\
|
||||
editor-line-height: 1.6\n\
|
||||
editor-max-width: 680\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Minimal Theme\n\
|
||||
\n\
|
||||
High contrast, minimal chrome. Monospace typography throughout.\n";
|
||||
/// Build a vault theme note string from a set of CSS variable pairs.
|
||||
///
|
||||
/// Values containing `#`, `'`, `,`, or `(` are YAML-quoted to avoid parse errors.
|
||||
fn build_vault_theme_note(name: &str, description: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut fm = format!("---\ntype: Theme\nDescription: {description}\n");
|
||||
for (key, value) in vars {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(')
|
||||
{
|
||||
fm.push_str(&format!("{key}: \"{value}\"\n"));
|
||||
} else {
|
||||
fm.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
}
|
||||
fm.push_str("---\n\n");
|
||||
fm.push_str(&format!("# {name} Theme\n\n{description}.\n"));
|
||||
fm
|
||||
}
|
||||
|
||||
/// Apply overrides on top of DEFAULT_VAULT_THEME_VARS, returning a new Vec.
|
||||
fn apply_overrides(
|
||||
overrides: &[(&'static str, &'static str)],
|
||||
) -> Vec<(&'static str, &'static str)> {
|
||||
let mut vars: Vec<(&'static str, &'static str)> = DEFAULT_VAULT_THEME_VARS.to_vec();
|
||||
for &(key, value) in overrides {
|
||||
if let Some(entry) = vars.iter_mut().find(|e| e.0 == key) {
|
||||
entry.1 = value;
|
||||
}
|
||||
}
|
||||
vars
|
||||
}
|
||||
|
||||
/// Generate the Default vault theme note content.
|
||||
pub fn default_vault_theme() -> String {
|
||||
build_vault_theme_note(
|
||||
"Default",
|
||||
"Light theme with warm, paper-like tones",
|
||||
&DEFAULT_VAULT_THEME_VARS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate the Dark vault theme note content.
|
||||
pub fn dark_vault_theme() -> String {
|
||||
let vars = apply_overrides(DARK_COLOR_OVERRIDES);
|
||||
build_vault_theme_note("Dark", "Dark variant with deep navy tones", &vars)
|
||||
}
|
||||
|
||||
/// Generate the Minimal vault theme note content.
|
||||
pub fn minimal_vault_theme() -> String {
|
||||
let vars = apply_overrides(MINIMAL_OVERRIDES);
|
||||
build_vault_theme_note("Minimal", "High contrast, minimal chrome", &vars)
|
||||
}
|
||||
|
||||
/// Type definition for the Theme note type.
|
||||
pub const THEME_TYPE_DEFINITION: &str = "---\n\
|
||||
|
||||
@@ -258,7 +258,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_vault_theme_content_contains_all_vars() {
|
||||
let content = DEFAULT_VAULT_THEME;
|
||||
let content = default_vault_theme();
|
||||
assert!(content.contains("background:"));
|
||||
assert!(content.contains("primary:"));
|
||||
assert!(content.contains("sidebar:"));
|
||||
|
||||
@@ -31,6 +31,15 @@ pub fn seed_default_themes(vault_path: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Write a vault theme file if it doesn't exist or is empty (corrupt).
|
||||
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(needs_write)
|
||||
}
|
||||
|
||||
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
|
||||
/// Per-file idempotent: creates the directory if missing, writes each default
|
||||
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
|
||||
@@ -40,19 +49,18 @@ pub fn seed_vault_themes(vault_path: &str) {
|
||||
if fs::create_dir_all(&theme_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let default_content = default_vault_theme();
|
||||
let dark_content = dark_vault_theme();
|
||||
let minimal_content = minimal_vault_theme();
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
("default.md", &default_content),
|
||||
("dark.md", &dark_content),
|
||||
("minimal.md", &minimal_content),
|
||||
];
|
||||
let mut seeded = false;
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, content);
|
||||
seeded = true;
|
||||
}
|
||||
let wrote = write_if_missing(&theme_dir.join(name), content).unwrap_or(false);
|
||||
seeded = seeded || wrote;
|
||||
}
|
||||
if seeded {
|
||||
log::info!("Seeded theme/ with built-in vault themes");
|
||||
@@ -64,17 +72,17 @@ pub fn seed_vault_themes(vault_path: &str) {
|
||||
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
let default_content = default_vault_theme();
|
||||
let dark_content = dark_vault_theme();
|
||||
let minimal_content = minimal_vault_theme();
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
("default.md", &default_content),
|
||||
("dark.md", &dark_content),
|
||||
("minimal.md", &minimal_content),
|
||||
];
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
|
||||
}
|
||||
write_if_missing(&theme_dir.join(name), content)
|
||||
.map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -93,12 +101,7 @@ pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
];
|
||||
for (name, content) in json_defaults {
|
||||
let path = themes_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write _themes/{name}: {e}"))?;
|
||||
}
|
||||
write_if_missing(&themes_dir.join(name), content)?;
|
||||
}
|
||||
|
||||
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
|
||||
@@ -114,12 +117,7 @@ pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
|
||||
pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> {
|
||||
let type_dir = Path::new(vault_path).join("type");
|
||||
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
|
||||
let path = type_dir.join("theme.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, THEME_TYPE_DEFINITION)
|
||||
.map_err(|e| format!("Failed to write type/theme.md: {e}"))?;
|
||||
}
|
||||
write_if_missing(&type_dir.join("theme.md"), THEME_TYPE_DEFINITION)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,7 +160,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
@@ -330,7 +328,7 @@ mod tests {
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
@@ -341,4 +339,54 @@ mod tests {
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Light theme with warm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seeded_default_theme_contains_editor_properties() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap();
|
||||
|
||||
// Must contain all editor properties from theme.json
|
||||
assert!(
|
||||
content.contains("editor-font-family:"),
|
||||
"missing editor-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("headings-h1-font-size:"),
|
||||
"missing headings-h1-font-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("lists-bullet-size:"),
|
||||
"missing lists-bullet-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("checkboxes-size:"),
|
||||
"missing checkboxes-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("inline-styles-bold-font-weight:"),
|
||||
"missing inline-styles-bold"
|
||||
);
|
||||
assert!(
|
||||
content.contains("code-blocks-font-family:"),
|
||||
"missing code-blocks-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("blockquote-border-left-width:"),
|
||||
"missing blockquote"
|
||||
);
|
||||
assert!(
|
||||
content.contains("table-border-color:"),
|
||||
"missing table-border-color"
|
||||
);
|
||||
assert!(
|
||||
content.contains("horizontal-rule-thickness:"),
|
||||
"missing horizontal-rule"
|
||||
);
|
||||
assert!(content.contains("colors-text:"), "missing colors-text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 5;
|
||||
const CACHE_VERSION: u32 = 6;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -870,4 +870,80 @@ mod tests {
|
||||
"note must be trashed after invalidate + rescan"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: a note with `Archived: Yes` (string, not boolean)
|
||||
/// must be recognized as archived through the full cached vault load path.
|
||||
/// This catches the scenario where a stale cache stores `archived: false`
|
||||
/// and the cache version bump forces a correct re-parse.
|
||||
#[test]
|
||||
fn test_cached_vault_archived_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(
|
||||
vault,
|
||||
"archived-note.md",
|
||||
"---\nArchived: Yes\n---\n# Old Note\n",
|
||||
);
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].archived,
|
||||
"'Archived: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn test_stale_cache_version_forces_rescan_of_archived_yes() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nArchived: Yes\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let hash = git_head_hash(vault).unwrap();
|
||||
|
||||
// Simulate a stale cache written by old code that parsed Archived: Yes as false
|
||||
let stale_entry = {
|
||||
let mut e = parse_md_file(&vault.join("note.md")).unwrap();
|
||||
e.archived = false; // simulate old parser behavior
|
||||
e
|
||||
};
|
||||
let stale_cache = VaultCache {
|
||||
version: CACHE_VERSION - 1, // old version
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: hash,
|
||||
entries: vec![stale_entry],
|
||||
};
|
||||
write_cache(vault, &stale_cache);
|
||||
|
||||
// Load via cached path — stale version must trigger full rescan
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].archived,
|
||||
"stale cache with old version must be invalidated, re-parsing 'Archived: Yes' as true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,17 +428,17 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
.map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("default.md"),
|
||||
crate::theme::DEFAULT_VAULT_THEME,
|
||||
crate::theme::default_vault_theme(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write default vault theme: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("dark.md"),
|
||||
crate::theme::DARK_VAULT_THEME,
|
||||
crate::theme::dark_vault_theme(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write dark vault theme: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("minimal.md"),
|
||||
crate::theme::MINIMAL_VAULT_THEME,
|
||||
crate::theme::minimal_vault_theme(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write minimal vault theme: {e}"))?;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult};
|
||||
pub use trash::{delete_note, is_file_trashed, purge_trash};
|
||||
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
|
||||
|
||||
use parsing::{
|
||||
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
|
||||
@@ -1261,6 +1261,17 @@ References:
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_deeply_nested_new_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("a/b/c/deep-note.md");
|
||||
let content = "---\ntitle: Deep\n---\n";
|
||||
|
||||
save_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
// --- sidebar_label tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -344,10 +344,16 @@ pub fn move_note_to_type_folder(
|
||||
}
|
||||
|
||||
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
/// the file's H1 heading. This is needed when the caller has already saved updated
|
||||
/// content to disk (e.g. the editor saved a new H1 before triggering the rename)
|
||||
/// so the on-disk H1 already matches `new_title`.
|
||||
pub fn rename_note(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_title: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
@@ -366,23 +372,34 @@ pub fn rename_note(
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let old_title = super::extract_title(&content, &old_filename);
|
||||
let extracted_title = super::extract_title(&content, &old_filename);
|
||||
let old_title = old_title_hint.unwrap_or(&extracted_title);
|
||||
|
||||
if old_title == new_title {
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
|
||||
let expected_filename = format!("{}.md", title_to_slug(new_title));
|
||||
let title_unchanged = old_title == new_title;
|
||||
let filename_matches = old_filename == expected_filename;
|
||||
|
||||
if title_unchanged && filename_matches {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Update content (H1 + frontmatter title)
|
||||
let updated_content = update_note_title_in_content(&content, new_title);
|
||||
// Update content only if the title actually changed
|
||||
let updated_content = if title_unchanged {
|
||||
content.clone()
|
||||
} else {
|
||||
update_note_title_in_content(&content, new_title)
|
||||
};
|
||||
|
||||
// Compute new path and write file
|
||||
// Compute new path, handling collisions with numeric suffix
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
|
||||
let new_file = unique_dest_path(parent_dir, &expected_filename);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
fs::write(&new_file, &updated_content)
|
||||
@@ -397,7 +414,7 @@ pub fn rename_note(
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title: &old_title,
|
||||
old_title,
|
||||
new_title,
|
||||
old_path_stem,
|
||||
exclude_path: &new_file,
|
||||
@@ -455,6 +472,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -491,6 +509,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -515,6 +534,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -529,7 +549,12 @@ mod tests {
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let old_path = vault.join("note/test.md");
|
||||
let result = rename_note(vault.to_str().unwrap(), old_path.to_str().unwrap(), " ");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
" ",
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -549,6 +574,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -572,6 +598,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"New Name",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -594,6 +621,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
new_title,
|
||||
None,
|
||||
)
|
||||
.expect("rename_note should succeed");
|
||||
|
||||
@@ -649,6 +677,82 @@ mod tests {
|
||||
assert!(content.contains("# Renamed Note"));
|
||||
}
|
||||
|
||||
// --- rename-on-save: filename doesn't match title slug ---
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_mismatch_same_title() {
|
||||
// Simulates: user created "Untitled note", changed H1 to "My New Note",
|
||||
// saved content (H1 now correct), but filename is still "untitled-note.md".
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/untitled-note.md",
|
||||
"---\ntitle: My New Note\ntype: Note\n---\n\n# My New Note\n\nContent.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My New Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// File should be renamed to match the title slug
|
||||
assert!(
|
||||
result.new_path.ends_with("my-new-note.md"),
|
||||
"expected my-new-note.md, got {}",
|
||||
result.new_path
|
||||
);
|
||||
assert!(!old_path.exists(), "old file should be removed");
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
|
||||
// Content should be preserved (title was already correct)
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(content.contains("# My New Note"));
|
||||
assert!(content.contains("title: My New Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_collision_appends_suffix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
// Existing file with the slug we want
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nExisting.\n",
|
||||
);
|
||||
// File with wrong name that should be renamed to my-note.md
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/untitled-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nNew content.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should get a suffixed name to avoid collision
|
||||
assert!(
|
||||
result.new_path.ends_with("my-note-2.md"),
|
||||
"expected my-note-2.md, got {}",
|
||||
result.new_path
|
||||
);
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
// Original file should be untouched
|
||||
assert!(vault.join("note/my-note.md").exists());
|
||||
}
|
||||
|
||||
// --- move_note_to_type_folder tests ---
|
||||
|
||||
#[test]
|
||||
@@ -826,6 +930,131 @@ mod tests {
|
||||
assert!(other_content.contains("[[Weekly Review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_collision_preserves_both_contents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let moving_content =
|
||||
"---\ntype: Quarter\n---\n# Migrate newsletter to Beehiiv\n\nImportant content.\n";
|
||||
let existing_content =
|
||||
"---\ntype: Quarter\n---\n# Feedback for Laputa\n\nCompletely different note.\n";
|
||||
create_test_file(vault, "note/my-note.md", moving_content);
|
||||
create_test_file(vault, "quarter/my-note.md", existing_content);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
// Must get a unique path, not the existing file's path
|
||||
assert!(result.new_path.contains("/quarter/my-note-2.md"));
|
||||
// Moved note must retain its own content
|
||||
let moved_content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert_eq!(moved_content, moving_content);
|
||||
// Existing note must be untouched
|
||||
let untouched = fs::read_to_string(vault.join("quarter/my-note.md")).unwrap();
|
||||
assert_eq!(untouched, existing_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
|
||||
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
// Note file already has the NEW H1 (simulating savePendingForPath before rename)
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Sprint Retrospective\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"project/my-project.md",
|
||||
"---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
// Without old_title_hint, rename_note would see H1 = "Sprint Retrospective" == new_title → noop
|
||||
// With old_title_hint = "Weekly Review", it knows to search for [[Weekly Review]] and replace
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
Some("Weekly Review"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
assert!(result.new_path.ends_with("sprint-retrospective.md"));
|
||||
assert!(!vault.join("note/weekly-review.md").exists());
|
||||
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(!other_content.contains("[[Weekly Review]]"));
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_without_hint_backward_compatible() {
|
||||
// Existing behavior: no hint, extracts title from H1
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"See [[Weekly Review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_hint_same_as_new_title_noop() {
|
||||
// If old_title_hint == new_title, should be a noop
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
Some("My Note"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_empty_type_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -88,6 +88,47 @@ pub fn is_file_trashed(path: &Path) -> bool {
|
||||
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).
|
||||
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);
|
||||
}
|
||||
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 deleted: Vec<String> = 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()))
|
||||
.filter_map(|entry| try_purge_file(entry.path()))
|
||||
.collect();
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `Trashed at` frontmatter is more than 30 days ago.
|
||||
/// Returns the list of deleted file paths.
|
||||
@@ -342,4 +383,81 @@ mod tests {
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("active.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_removes_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "a.md", "---\ntitle: A\n---\n# A\n");
|
||||
create_test_file(dir.path(), "b.md", "---\ntitle: B\n---\n# B\n");
|
||||
create_test_file(dir.path(), "keep.md", "---\ntitle: Keep\n---\n# Keep\n");
|
||||
|
||||
let paths = vec![
|
||||
dir.path().join("a.md").to_str().unwrap().to_string(),
|
||||
dir.path().join("b.md").to_str().unwrap().to_string(),
|
||||
];
|
||||
let deleted = batch_delete_notes(&paths).unwrap();
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!dir.path().join("a.md").exists());
|
||||
assert!(!dir.path().join("b.md").exists());
|
||||
assert!(dir.path().join("keep.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_skips_nonexistent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "exists.md", "---\ntitle: X\n---\n# X\n");
|
||||
|
||||
let paths = vec![
|
||||
dir.path().join("exists.md").to_str().unwrap().to_string(),
|
||||
"/nonexistent/path.md".to_string(),
|
||||
];
|
||||
let deleted = batch_delete_notes(&paths).unwrap();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
98
src/App.tsx
98
src/App.tsx
@@ -17,7 +17,7 @@ import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { useNoteActions, needsRenameOnSave } from './hooks/useNoteActions'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
@@ -38,7 +38,10 @@ import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useNavigationGestures } from './hooks/useNavigationGestures'
|
||||
import { useAiActivity } from './hooks/useAiActivity'
|
||||
import { useBulkActions } from './hooks/useBulkActions'
|
||||
import { useDeleteActions } from './hooks/useDeleteActions'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
@@ -60,31 +63,6 @@ declare global {
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
function useBulkActions(
|
||||
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void> },
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const handleBulkArchive = useCallback(async (paths: string[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleArchiveNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleTrashNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
return { handleBulkArchive, handleBulkTrash }
|
||||
}
|
||||
|
||||
function useLayoutPanels() {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(250)
|
||||
const [noteListWidth, setNoteListWidth] = useState(300)
|
||||
@@ -306,10 +284,16 @@ function App() {
|
||||
triggerIncrementalIndex()
|
||||
}, [vault, triggerIncrementalIndex])
|
||||
|
||||
const { notifyThemeSaved } = themeManager
|
||||
const onNotePersisted = useCallback((path: string, content: string) => {
|
||||
vault.clearUnsaved(path)
|
||||
notifyThemeSaved(path, content)
|
||||
}, [vault, notifyThemeSaved])
|
||||
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateEntry: vault.updateEntry,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave,
|
||||
onNotePersisted: vault.clearUnsaved,
|
||||
onNotePersisted,
|
||||
})
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
@@ -352,14 +336,25 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
|
||||
// and trigger file rename when the title slug doesn't match the filename.
|
||||
const handleSave = useCallback(async () => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
|
||||
? { path: activeTab.entry.path, content: activeTab.content }
|
||||
: undefined
|
||||
await handleSaveRaw(fallback)
|
||||
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
// After saving, check if filename needs to match the current title
|
||||
if (activeTab && needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)) {
|
||||
await handleRenameTab(activeTab.entry.path, activeTab.entry.title)
|
||||
}
|
||||
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
|
||||
|
||||
@@ -372,17 +367,13 @@ function App() {
|
||||
onBeforeAction: flushBeforeAction,
|
||||
})
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
try {
|
||||
if (isTauri()) await invoke('delete_note', { path })
|
||||
else await mockInvoke('delete_note', { path })
|
||||
notes.handleCloseTab(path)
|
||||
vault.removeEntry(path)
|
||||
setToastMessage('Note permanently deleted')
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to delete note: ${e}`)
|
||||
}
|
||||
}, [notes, vault, setToastMessage])
|
||||
const deleteActions = useDeleteActions({
|
||||
vaultPath: resolvedPath,
|
||||
entries: vault.entries,
|
||||
handleCloseTab: notes.handleCloseTab,
|
||||
removeEntry: vault.removeEntry,
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
|
||||
@@ -391,19 +382,12 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
/** H1→title sync: save pending content then rename file + update wikilinks. */
|
||||
const handleTitleSync = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
|
||||
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
||||
vault.updateEntry(path, { title: newTitle })
|
||||
notes.setTabs(prev => prev.map(t =>
|
||||
t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t
|
||||
))
|
||||
}, [vault, notes])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
@@ -516,6 +500,9 @@ function App() {
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
onEmptyTrash: deleteActions.handleEmptyTrash,
|
||||
trashedCount: deleteActions.trashedCount,
|
||||
onReopenClosedTab: notes.handleReopenClosedTab,
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
@@ -582,7 +569,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -611,6 +598,7 @@ function App() {
|
||||
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
@@ -618,7 +606,7 @@ function App() {
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onDeleteNote={deleteActions.handleDeleteNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
@@ -667,6 +655,16 @@ function App() {
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title={deleteActions.confirmDelete.title}
|
||||
message={deleteActions.confirmDelete.message}
|
||||
confirmLabel={deleteActions.confirmDelete.confirmLabel}
|
||||
onConfirm={deleteActions.confirmDelete.onConfirm}
|
||||
onCancel={() => deleteActions.setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
50
src/components/BulkActionBar.test.tsx
Normal file
50
src/components/BulkActionBar.test.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
|
||||
describe('BulkActionBar', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
onArchive: vi.fn(),
|
||||
onTrash: vi.fn(),
|
||||
onRestore: vi.fn(),
|
||||
onDeletePermanently: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
isTrashView: false,
|
||||
}
|
||||
|
||||
it('shows Archive and Trash 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 and Delete permanently in trash view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} />)
|
||||
expect(screen.getByTestId('bulk-restore-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-trash-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
render(<BulkActionBar {...defaultProps} count={5} />)
|
||||
expect(screen.getByText('5 selected')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,20 @@
|
||||
import { memo } from 'react'
|
||||
import { Archive, Trash, X } from '@phosphor-icons/react'
|
||||
import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
|
||||
|
||||
interface BulkActionBarProps {
|
||||
count: number
|
||||
isTrashView: boolean
|
||||
onArchive: () => void
|
||||
onTrash: () => void
|
||||
onRestore: () => void
|
||||
onDeletePermanently: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, onArchive, onTrash, onClear }: 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 BulkActionBarInner({ count, isTrashView, onArchive, onTrash, onRestore, onDeletePermanently, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
@@ -24,26 +30,53 @@ function BulkActionBarInner({ count, onArchive, onTrash, onClear }: BulkActionBa
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 }}
|
||||
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={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={onTrash}
|
||||
title="Move selected notes to trash"
|
||||
data-testid="bulk-trash-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Trash
|
||||
</button>
|
||||
{isTrashView ? (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={actionBtnStyle}
|
||||
onClick={onRestore}
|
||||
title="Restore selected notes"
|
||||
data-testid="bulk-restore-btn"
|
||||
>
|
||||
<ArrowCounterClockwise size={14} />
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={destructiveBtnStyle}
|
||||
onClick={onDeletePermanently}
|
||||
title="Permanently delete selected notes"
|
||||
data-testid="bulk-delete-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Delete permanently
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={actionBtnStyle}
|
||||
onClick={onArchive}
|
||||
title="Archive selected notes"
|
||||
data-testid="bulk-archive-btn"
|
||||
>
|
||||
<Archive size={14} />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={destructiveBtnStyle}
|
||||
onClick={onTrash}
|
||||
title="Move selected notes to trash"
|
||||
data-testid="bulk-trash-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Trash
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
|
||||
81
src/components/ConfirmDeleteDialog.test.tsx
Normal file
81
src/components/ConfirmDeleteDialog.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
||||
|
||||
describe('ConfirmDeleteDialog', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders with title and message', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Delete permanently?')).toBeInTheDocument()
|
||||
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when delete button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-btn'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={false}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('Delete permanently?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses custom confirm label when provided', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Empty Trash?"
|
||||
message="Delete all notes?"
|
||||
confirmLabel="Empty Trash"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Empty Trash')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
49
src/components/ConfirmDeleteDialog.tsx
Normal file
49
src/components/ConfirmDeleteDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { memo } from 'react'
|
||||
import { Trash } from '@phosphor-icons/react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Delete permanently',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
|
||||
<DialogContent showCloseButton={false} data-testid="confirm-delete-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash size={18} className="text-destructive" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-btn">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
@@ -31,6 +31,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Drag-over state: subtle border highlight */
|
||||
|
||||
@@ -13,6 +13,8 @@ const mockEditor = vi.hoisted(() => ({
|
||||
prosemirrorView: {} as Record<string, unknown>,
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
focus: vi.fn(),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock BlockNote components
|
||||
@@ -358,6 +360,68 @@ describe('Editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('click empty editor space', () => {
|
||||
it('focuses editor at end of last block when clicking empty space below content', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
|
||||
render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
||||
)
|
||||
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
// Click directly on the container (simulates clicking empty space below content)
|
||||
fireEvent.click(container!)
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('1', 'end')
|
||||
expect(mockEditor.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not interfere with clicks on contenteditable elements', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
|
||||
render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
||||
)
|
||||
|
||||
// Simulate clicking on a contenteditable child (which ProseMirror would handle)
|
||||
const container = document.querySelector('.editor__blocknote-container')!
|
||||
const editableDiv = document.createElement('div')
|
||||
editableDiv.setAttribute('contenteditable', 'true')
|
||||
container.appendChild(editableDiv)
|
||||
|
||||
fireEvent.click(editableDiv)
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
// Clean up
|
||||
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}
|
||||
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', () => {
|
||||
it('shows archive banner immediately when entry changes to archived (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -45,6 +45,7 @@ interface EditorProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
vaultPath?: string
|
||||
@@ -120,7 +121,7 @@ export const Editor = memo(function Editor({
|
||||
onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
@@ -149,9 +150,24 @@ export const Editor = memo(function Editor({
|
||||
onTitleSync: onTitleSync ?? (() => {}),
|
||||
})
|
||||
|
||||
// Ref updated by RawEditorView on every keystroke — used to flush
|
||||
// debounced content synchronously before leaving raw mode.
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
|
||||
const handleBeforeRawEnd = useCallback(() => {
|
||||
if (rawLatestContentRef.current != null && activeTabPath) {
|
||||
onContentChange?.(activeTabPath, rawLatestContentRef.current)
|
||||
}
|
||||
rawLatestContentRef.current = null
|
||||
}, [activeTabPath, onContentChange])
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({
|
||||
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
|
||||
})
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
tabs, activeTabPath, editor, onContentChange,
|
||||
onH1Change: onH1Changed, syncActiveRef,
|
||||
onH1Change: onH1Changed, syncActiveRef, rawMode,
|
||||
})
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
|
||||
@@ -165,8 +181,6 @@ export const Editor = memo(function Editor({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
})
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
|
||||
|
||||
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
|
||||
})
|
||||
@@ -223,6 +237,7 @@ export const Editor = memo(function Editor({
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
@@ -245,6 +260,7 @@ export const Editor = memo(function Editor({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onOpenNote={onNavigateWikilink}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
@@ -41,6 +42,8 @@ interface EditorContentProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
/** Ref updated by RawEditorView on every keystroke with the latest doc. */
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -72,7 +75,7 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
|
||||
}
|
||||
|
||||
function RawModeEditorSection({
|
||||
rawMode, activeTab, entries, onContentChange, onSave, isDark,
|
||||
rawMode, activeTab, entries, onContentChange, onSave, isDark, latestContentRef,
|
||||
}: {
|
||||
rawMode: boolean
|
||||
activeTab: Tab | null
|
||||
@@ -80,6 +83,7 @@ function RawModeEditorSection({
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
isDark?: boolean
|
||||
latestContentRef?: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
if (!rawMode || !activeTab) return null
|
||||
return (
|
||||
@@ -91,6 +95,7 @@ function RawModeEditorSection({
|
||||
onContentChange={onContentChange ?? (() => {})}
|
||||
onSave={onSave ?? (() => {})}
|
||||
isDark={isDark}
|
||||
latestContentRef={latestContentRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -129,19 +134,20 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
)
|
||||
}
|
||||
|
||||
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed }: {
|
||||
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed, rawLatestContentRef }: {
|
||||
activeTab: Tab | null; isLoadingNewTab: boolean; entries: VaultEntry[]
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
diffMode: boolean; diffContent: string | null; onToggleDiff: () => void
|
||||
rawMode: boolean; onRawContentChange?: (path: string, content: string) => void; onSave?: () => void
|
||||
onNavigateWikilink: (target: string) => void; onEditorChange?: () => void
|
||||
vaultPath?: string; isDarkTheme?: boolean; isTrashed: boolean
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
return (
|
||||
<>
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} />
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
|
||||
{showEditor && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
|
||||
@@ -157,7 +163,7 @@ export function EditorContent({
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
onDeleteNote,
|
||||
onDeleteNote, rawLatestContentRef,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const isTrashed = activeTab?.entry.trashed ?? false
|
||||
@@ -179,7 +185,7 @@ export function EditorContent({
|
||||
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
|
||||
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
|
||||
)}
|
||||
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} />
|
||||
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} rawLatestContentRef={rawLatestContentRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface EditorRightPanelProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
@@ -33,7 +34,7 @@ export function EditorRightPanel({
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
if (showAIChat) {
|
||||
@@ -79,6 +80,7 @@ export function EditorRightPanel({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ interface InspectorProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function useBacklinks(
|
||||
@@ -114,7 +115,7 @@ function EmptyInspector() {
|
||||
|
||||
export function Inspector({
|
||||
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
}: InspectorProps) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
@@ -157,6 +158,7 @@ export function Inspector({
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
|
||||
@@ -407,6 +407,190 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create & open from inline add', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onCreateAndOpenNote.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('shows "Create & open" option when typed title does not match any note', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Create & open/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Brand New Note/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when typed title matches an existing note', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
|
||||
await vi.waitFor(() => {
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add wikilink when note creation fails', async () => {
|
||||
onCreateAndOpenNote.mockResolvedValue(false)
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Failing Note' } })
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note')
|
||||
// Give async handler time to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalled()
|
||||
})
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows both existing matches and "Create & open" for partial matches', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
// "My" partially matches "My Project" but is not an exact match
|
||||
fireEvent.change(input, { target: { value: 'My' } })
|
||||
// Should show search results AND create option
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create & open from AddRelationshipForm', () => {
|
||||
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onCreateAndOpenNote.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('shows "Create & open" option in target input when title does not exist', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{}}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Link existing'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
const noteInput = screen.getByPlaceholderText('Note title')
|
||||
fireEvent.focus(noteInput)
|
||||
fireEvent.change(noteInput, { target: { value: 'New Person' } })
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates note and adds relationship via form', async () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{}}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Link existing'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
const noteInput = screen.getByPlaceholderText('Note title')
|
||||
fireEvent.focus(noteInput)
|
||||
fireEvent.change(noteInput, { target: { value: 'New Person' } })
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
|
||||
await vi.waitFor(() => {
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('BacklinksPanel', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
|
||||
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, Trash,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteItem, getTypeIcon } from './NoteItem'
|
||||
@@ -35,6 +35,9 @@ interface NoteListProps {
|
||||
onCreateNote: () => 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
|
||||
}
|
||||
@@ -423,10 +426,12 @@ function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boo
|
||||
|
||||
// --- Header component ---
|
||||
|
||||
function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
|
||||
function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
isTrashView: boolean
|
||||
trashCount: number
|
||||
listSort: SortOption
|
||||
listDirection: SortDirection
|
||||
customProperties: string[]
|
||||
@@ -438,6 +443,7 @@ function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirec
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onEmptyTrash?: () => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -456,9 +462,21 @@ function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirec
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
|
||||
<MagnifyingGlass 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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{searchVisible && (
|
||||
@@ -484,7 +502,7 @@ function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNot
|
||||
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
@@ -505,7 +523,11 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
|
||||
|
||||
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])
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, handleBulkArchive, handleBulkTrash)
|
||||
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
|
||||
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
|
||||
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : handleBulkArchive
|
||||
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
|
||||
@@ -516,7 +538,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
|
||||
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} />
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
<div className="flex-1 overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
{entitySelection ? (
|
||||
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
@@ -525,7 +547,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
|
||||
)}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onClear={multiSelect.clear} />
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onClear={multiSelect.clear} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface RawEditorViewProps {
|
||||
onContentChange: (path: string, content: string) => void
|
||||
onSave: () => void
|
||||
isDark?: boolean
|
||||
/** Mutable ref updated on every keystroke with the latest doc string.
|
||||
* Allows the parent to flush debounced content before unmount. */
|
||||
latestContentRef?: React.MutableRefObject<string | null>
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 500
|
||||
@@ -35,7 +38,7 @@ function getCursorCoords(view: EditorView): { top: number; left: number } | null
|
||||
return { top: coords.bottom, left: coords.left }
|
||||
}
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, isDark = false }: RawEditorViewProps) {
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, isDark = false, latestContentRef }: RawEditorViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pathRef = useRef(path)
|
||||
@@ -43,6 +46,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
const onSaveRef = useRef(onSave)
|
||||
const latestDocRef = useRef(content)
|
||||
useEffect(() => { pathRef.current = path }, [path])
|
||||
// Expose latest doc content to parent via ref
|
||||
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
@@ -64,8 +69,12 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
|
||||
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
|
||||
|
||||
const latestContentRefStable = useRef(latestContentRef)
|
||||
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
|
||||
|
||||
const handleDocChange = useCallback((doc: string) => {
|
||||
latestDocRef.current = doc
|
||||
if (latestContentRefStable.current) latestContentRefStable.current.current = doc
|
||||
setYamlError(detectYamlError(doc))
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
|
||||
73
src/components/Sidebar.test.ts
Normal file
73
src/components/Sidebar.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildSectionGroup } from '../utils/sidebarSections'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { GearSix, CookingPot, FileText } from '@phosphor-icons/react'
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
path: '', filename: '', title: '', isA: null, aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
|
||||
describe('buildSectionGroup', () => {
|
||||
it('uses type entry icon/color/sidebarLabel for custom type', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'blue', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('Config', typeEntryMap)
|
||||
expect(group.label).toBe('Config')
|
||||
expect(group.customColor).toBe('blue')
|
||||
expect(group.Icon).toBe(GearSix)
|
||||
})
|
||||
|
||||
it('uses type entry icon/color for custom type with custom icon', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Recipe: { ...baseEntry, title: 'Recipe', isA: 'Type', icon: 'cooking-pot', color: 'orange' },
|
||||
}
|
||||
const group = buildSectionGroup('Recipe', typeEntryMap)
|
||||
expect(group.label).toBe('Recipes')
|
||||
expect(group.customColor).toBe('orange')
|
||||
expect(group.Icon).toBe(CookingPot)
|
||||
})
|
||||
|
||||
it('falls back to pluralized name and FileText when no type entry', () => {
|
||||
const group = buildSectionGroup('Widget', {})
|
||||
expect(group.label).toBe('Widgets')
|
||||
expect(group.customColor).toBeNull()
|
||||
expect(group.Icon).toBe(FileText)
|
||||
})
|
||||
|
||||
it('overrides built-in type icon/color when type entry has custom values', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Project: { ...baseEntry, title: 'Project', isA: 'Type', icon: 'rocket', color: 'green', sidebarLabel: 'My Projects' },
|
||||
}
|
||||
const group = buildSectionGroup('Project', typeEntryMap)
|
||||
expect(group.label).toBe('My Projects')
|
||||
expect(group.customColor).toBe('green')
|
||||
expect(group.Icon).toBe(resolveIcon('rocket'))
|
||||
})
|
||||
|
||||
it('uses gray color for Config type', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('Config', typeEntryMap)
|
||||
expect(group.customColor).toBe('gray')
|
||||
})
|
||||
|
||||
it('resolves type entry via lowercase key (case-insensitive isA)', () => {
|
||||
// When instances have isA: 'config' (lowercase) but type entry title is 'Config'
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('config', typeEntryMap)
|
||||
expect(group.label).toBe('Config')
|
||||
expect(group.customColor).toBe('gray')
|
||||
expect(group.Icon).toBe(GearSix)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
@@ -13,8 +12,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -41,20 +39,6 @@ interface SidebarProps {
|
||||
isGitVault?: boolean
|
||||
}
|
||||
|
||||
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Projects', type: 'Project', Icon: Wrench },
|
||||
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
|
||||
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
|
||||
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
|
||||
{ label: 'People', type: 'Person', Icon: Users },
|
||||
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
|
||||
{ label: 'Topics', type: 'Topic', Icon: Tag },
|
||||
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
||||
]
|
||||
|
||||
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
|
||||
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
|
||||
|
||||
// --- Hooks ---
|
||||
|
||||
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
|
||||
@@ -68,42 +52,6 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
|
||||
}, [ref, isOpen, onClose])
|
||||
}
|
||||
|
||||
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
|
||||
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
|
||||
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
|
||||
const builtIn = BUILT_IN_TYPE_MAP.get(type)
|
||||
const typeEntry = typeEntryMap[type]
|
||||
const customColor = typeEntry?.color ?? null
|
||||
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
|
||||
if (builtIn) {
|
||||
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
|
||||
return { ...builtIn, label, Icon, customColor }
|
||||
}
|
||||
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
}
|
||||
|
||||
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
|
||||
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const orderA = typeEntryMap[a.type]?.order ?? Infinity
|
||||
const orderB = typeEntryMap[b.type]?.order ?? Infinity
|
||||
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
function useSidebarSections(entries: VaultEntry[]) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const allSectionGroups = useMemo(() => {
|
||||
|
||||
@@ -39,6 +39,17 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[contenteditable="true"]')) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
}
|
||||
editor.focus()
|
||||
}, [editor, editable])
|
||||
|
||||
useEffect(() => {
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
@@ -94,7 +105,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
}, [baseItems, insertWikilink, typeEntryMap])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties}>
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>
|
||||
{isDragOver && (
|
||||
<div className="editor__drop-overlay">
|
||||
<div className="editor__drop-overlay-label">Drop image here</div>
|
||||
|
||||
@@ -218,6 +218,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-tab-path={tab.entry.path}
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */
|
||||
import { BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core'
|
||||
import { createReactInlineContentSpec } from '@blocknote/react'
|
||||
import { resolveWikilinkColor as resolveColor, findEntryByTarget } from '../utils/wikilinkColors'
|
||||
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Module-level cache so the WikiLink renderer (defined outside React) can access entries
|
||||
@@ -16,7 +17,7 @@ function resolveWikilinkColor(target: string) {
|
||||
function resolveDisplayText(target: string): string {
|
||||
const pipeIdx = target.indexOf('|')
|
||||
if (pipeIdx !== -1) return target.slice(pipeIdx + 1)
|
||||
const entry = findEntryByTarget(_wikilinkEntriesRef.current, target)
|
||||
const entry = resolveEntry(_wikilinkEntriesRef.current, target)
|
||||
if (entry) return entry.title
|
||||
const last = target.split('/').pop() ?? target
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
@@ -1,57 +1,138 @@
|
||||
import { useMemo, useCallback, useState, useRef } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import type { ParsedFrontmatter } from '../../utils/frontmatter'
|
||||
import { containsWikilinks } from '../DynamicPropertiesPanel'
|
||||
import type { FrontmatterValue } from '../Inspector'
|
||||
import { NoteSearchList } from '../NoteSearchList'
|
||||
import { useNoteSearch } from '../../hooks/useNoteSearch'
|
||||
import { resolveEntry } from '../../utils/wikilink'
|
||||
import { isWikilink, resolveRefProps } from './shared'
|
||||
import { LinkButton } from './LinkButton'
|
||||
|
||||
function SearchDropdown({ search, onSelect }: {
|
||||
search: ReturnType<typeof useNoteSearch>
|
||||
onSelect: (title: string) => void
|
||||
/** Check whether any entry resolves for the given title (exact match via wikilink resolution). */
|
||||
function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean {
|
||||
return resolveEntry(entries, title) !== undefined
|
||||
}
|
||||
|
||||
function CreateAndOpenOption({ title, selected, onClick, onHover }: {
|
||||
title: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
onHover: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
|
||||
<NoteSearchList
|
||||
items={search.results}
|
||||
selectedIndex={search.selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => onSelect(item.entry.title)}
|
||||
onItemHover={(i) => search.setSelectedIndex(i)}
|
||||
className="max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm transition-colors ${selected ? 'bg-accent' : 'hover:bg-secondary'}`}
|
||||
data-testid="create-and-open-option"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
<Plus size={14} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-foreground">
|
||||
Create & open <strong>{title}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, onAdd }: {
|
||||
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
|
||||
search: ReturnType<typeof useNoteSearch>
|
||||
onSelect: (title: string) => void
|
||||
query: string
|
||||
entries: VaultEntry[]
|
||||
onCreateAndOpen?: (title: string) => void
|
||||
}) {
|
||||
const trimmed = query.trim()
|
||||
const showCreate = !!onCreateAndOpen && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
|
||||
const hasResults = search.results.length > 0
|
||||
const createIndex = search.results.length
|
||||
|
||||
if (!hasResults && !showCreate) return null
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
|
||||
{hasResults && (
|
||||
<NoteSearchList
|
||||
items={search.results}
|
||||
selectedIndex={search.selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => onSelect(item.entry.title)}
|
||||
onItemHover={(i) => search.setSelectedIndex(i)}
|
||||
className="max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
)}
|
||||
{showCreate && (
|
||||
<CreateAndOpenOption
|
||||
title={trimmed}
|
||||
selected={search.selectedIndex === createIndex}
|
||||
onClick={() => onCreateAndOpen(trimmed)}
|
||||
onHover={() => search.setSelectedIndex(createIndex)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
onAdd: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const [active, setActive] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const search = useNoteSearch(entries, query, 8)
|
||||
|
||||
const trimmed = query.trim()
|
||||
const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
|
||||
const createIndex = search.results.length
|
||||
|
||||
const selectAndClose = useCallback((title: string) => {
|
||||
onAdd(title)
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}, [onAdd])
|
||||
|
||||
const handleCreateAndOpen = useCallback(async () => {
|
||||
if (!onCreateAndOpenNote) return
|
||||
const title = trimmed
|
||||
if (!title) return
|
||||
const ok = await onCreateAndOpenNote(title)
|
||||
if (ok) {
|
||||
onAdd(title)
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}
|
||||
}, [onCreateAndOpenNote, trimmed, onAdd])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const title = search.selectedEntry?.title ?? query.trim()
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
handleCreateAndOpen()
|
||||
return
|
||||
}
|
||||
const title = search.selectedEntry?.title ?? trimmed
|
||||
if (title) selectAndClose(title)
|
||||
}, [search.selectedEntry, query, selectAndClose])
|
||||
}, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen])
|
||||
|
||||
const totalItems = search.results.length + (showCreate ? 1 : 0)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
search.handleKeyDown(e)
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleConfirm() }
|
||||
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
|
||||
}, [search, handleConfirm])
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleConfirm()
|
||||
} else if (e.key === 'Escape') {
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}
|
||||
}, [search, totalItems, handleConfirm])
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
@@ -66,6 +147,8 @@ function InlineAddNote({ entries, onAdd }: {
|
||||
)
|
||||
}
|
||||
|
||||
const showDropdown = query.trim().length > 0 && (search.results.length > 0 || showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative mt-1">
|
||||
<div className="group/add relative flex items-center">
|
||||
@@ -87,17 +170,31 @@ function InlineAddNote({ entries, onAdd }: {
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{query.trim() && search.results.length > 0 && (
|
||||
<SearchDropdown search={search} onSelect={selectAndClose} />
|
||||
{showDropdown && (
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={selectAndClose}
|
||||
query={query}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => {
|
||||
const fn = async () => {
|
||||
const ok = await onCreateAndOpenNote(title)
|
||||
if (ok) { onAdd(title); setQuery(''); setActive(false) }
|
||||
}
|
||||
fn()
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef }: {
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
|
||||
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void
|
||||
onRemoveRef?: (ref: string) => void
|
||||
onAddRef?: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
if (refs.length === 0) return null
|
||||
return (
|
||||
@@ -116,7 +213,13 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
|
||||
{onAddRef && (
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
onAdd={onAddRef}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -133,26 +236,46 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
|
||||
.filter(({ refs }) => refs.length > 0)
|
||||
}
|
||||
|
||||
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
|
||||
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit?: () => void
|
||||
onCancel?: () => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onSubmitWithCreate?: (title: string) => void
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const search = useNoteSearch(entries, value, 8)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
search.handleKeyDown(e)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) }
|
||||
else onSubmit?.()
|
||||
} else if (e.key === 'Escape') { onCancel?.() }
|
||||
}, [search, onChange, onSubmit, onCancel])
|
||||
const trimmed = value.trim()
|
||||
const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
|
||||
const createIndex = search.results.length
|
||||
const totalItems = search.results.length + (showCreate ? 1 : 0)
|
||||
|
||||
const showDropdown = focused && value.trim() && search.results.length > 0
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
onSubmitWithCreate?.(trimmed)
|
||||
} else if (search.selectedEntry) {
|
||||
onChange(search.selectedEntry.title)
|
||||
setFocused(false)
|
||||
} else {
|
||||
onSubmit?.()
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel?.()
|
||||
}
|
||||
}, [search, totalItems, showCreate, createIndex, trimmed, onChange, onSubmit, onCancel, onSubmitWithCreate])
|
||||
|
||||
const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -167,15 +290,22 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{showDropdown && (
|
||||
<SearchDropdown search={search} onSelect={(title) => { onChange(title); setFocused(false) }} />
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={(title) => { onChange(title); setFocused(false) }}
|
||||
query={value}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, onAddProperty }: {
|
||||
function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
onAddProperty: (key: string, value: FrontmatterValue) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const [relKey, setRelKey] = useState('')
|
||||
const [relTarget, setRelTarget] = useState('')
|
||||
@@ -190,6 +320,17 @@ function AddRelationshipForm({ entries, onAddProperty }: {
|
||||
setRelKey(''); setRelTarget(''); setShowForm(false)
|
||||
}, [relKey, relTarget, onAddProperty])
|
||||
|
||||
const handleCreateAndSubmit = useCallback(async (title: string) => {
|
||||
if (!onCreateAndOpenNote) return
|
||||
const key = relKey.trim()
|
||||
if (!key) return
|
||||
const ok = await onCreateAndOpenNote(title)
|
||||
if (ok) {
|
||||
onAddProperty(key, `[[${title}]]`)
|
||||
setRelKey(''); setRelTarget(''); setShowForm(false)
|
||||
}
|
||||
}, [onCreateAndOpenNote, relKey, onAddProperty])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setShowForm(false); setRelKey(''); setRelTarget('')
|
||||
}, [])
|
||||
@@ -212,7 +353,15 @@ function AddRelationshipForm({ entries, onAddProperty }: {
|
||||
onChange={e => setRelKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
|
||||
/>
|
||||
<NoteTargetInput entries={entries} value={relTarget} onChange={setRelTarget} onSubmit={submitForm} onCancel={resetForm} />
|
||||
<NoteTargetInput
|
||||
entries={entries}
|
||||
value={relTarget}
|
||||
onChange={setRelTarget}
|
||||
onSubmit={submitForm}
|
||||
onCancel={resetForm}
|
||||
onCreateAndOpenNote={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="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
|
||||
@@ -234,12 +383,13 @@ function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterVa
|
||||
return updated.length === 1 ? updated[0] : updated
|
||||
}
|
||||
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
|
||||
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
|
||||
|
||||
@@ -268,10 +418,11 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
|
||||
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
|
||||
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
|
||||
/>
|
||||
))}
|
||||
{onAddProperty
|
||||
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
|
||||
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
|
||||
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,9 @@ interface AppCommandsConfig {
|
||||
vaultCount?: number
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReopenClosedTab?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
@@ -114,6 +117,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onGoForward: config.onGoForward,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
})
|
||||
@@ -153,6 +157,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -206,6 +212,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
vaultCount: config.vaultCount,
|
||||
mcpStatus: config.mcpStatus,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
trashedCount: config.trashedCount,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
|
||||
@@ -183,6 +183,23 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onZoomReset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+T triggers reopen closed tab', () => {
|
||||
const actions = makeActions()
|
||||
const onReopenClosedTab = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onReopenClosedTab }))
|
||||
fireKey('t', { metaKey: true, shiftKey: true })
|
||||
expect(onReopenClosedTab).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+T does not trigger other shortcuts', () => {
|
||||
const actions = makeActions()
|
||||
const onReopenClosedTab = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onReopenClosedTab }))
|
||||
fireKey('t', { metaKey: true, shiftKey: true })
|
||||
expect(actions.onQuickOpen).not.toHaveBeenCalled()
|
||||
expect(actions.onCreateNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+I triggers toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
|
||||
@@ -19,6 +19,7 @@ interface KeyboardActions {
|
||||
onGoForward?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
}
|
||||
@@ -64,7 +65,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, activeTabPathRef, handleCloseTabRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -100,11 +101,17 @@ export function useAppKeyboard({
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
// Cmd+Shift+T: reopen last closed tab
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 't' || e.key === 'T')) {
|
||||
e.preventDefault()
|
||||
onReopenClosedTab?.()
|
||||
return
|
||||
}
|
||||
if (!handleViewModeKey(e, onSetViewMode)) {
|
||||
handleCmdKey(e, cmdKeyMap)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab])
|
||||
}
|
||||
|
||||
180
src/hooks/useBulkActions.test.ts
Normal file
180
src/hooks/useBulkActions.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
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 },
|
||||
setToastMessage,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- handleBulkArchive ---
|
||||
|
||||
describe('handleBulkArchive', () => {
|
||||
it('archives each path and shows plural toast for multiple notes', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(handleArchiveNote).toHaveBeenCalledTimes(2)
|
||||
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/a.md')
|
||||
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/b.md')
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
|
||||
})
|
||||
|
||||
it('shows singular toast when one note archived', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md'])
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('1 note archived')
|
||||
})
|
||||
|
||||
it('does not show toast when empty array given', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive([])
|
||||
})
|
||||
expect(handleArchiveNote).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips failed paths and only counts successes in toast', async () => {
|
||||
handleArchiveNote
|
||||
.mockResolvedValueOnce(undefined) // /vault/a.md succeeds
|
||||
.mockRejectedValueOnce(new Error('fail')) // /vault/b.md fails
|
||||
.mockResolvedValueOnce(undefined) // /vault/c.md succeeds
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
|
||||
})
|
||||
expect(handleArchiveNote).toHaveBeenCalledTimes(3)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
|
||||
})
|
||||
|
||||
it('shows no toast when all paths fail', async () => {
|
||||
handleArchiveNote.mockRejectedValue(new Error('fail'))
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
41
src/hooks/useBulkActions.ts
Normal file
41
src/hooks/useBulkActions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
interface BulkEntryActions {
|
||||
handleArchiveNote: (path: string) => Promise<void>
|
||||
handleTrashNote: (path: string) => Promise<void>
|
||||
handleRestoreNote: (path: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useBulkActions(
|
||||
entryActions: BulkEntryActions,
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const handleBulkArchive = useCallback(async (paths: string[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleArchiveNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
||||
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 }
|
||||
}
|
||||
99
src/hooks/useClosedTabHistory.test.ts
Normal file
99
src/hooks/useClosedTabHistory.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useClosedTabHistory } from './useClosedTabHistory'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const stubEntry = (path: string): VaultEntry => ({
|
||||
path, filename: path.split('/').pop() ?? '', title: path.split('/').pop()?.replace(/\.md$/, '') ?? '',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 0, createdAt: 0, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
|
||||
})
|
||||
|
||||
describe('useClosedTabHistory', () => {
|
||||
it('starts with empty history', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
expect(result.current.canReopen).toBe(false)
|
||||
})
|
||||
|
||||
it('records a closed tab and allows reopening', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => { result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md')) })
|
||||
|
||||
expect(result.current.canReopen).toBe(true)
|
||||
const entry = result.current.pop()
|
||||
expect(entry?.path).toBe('/vault/a.md')
|
||||
expect(entry?.index).toBe(0)
|
||||
expect(result.current.canReopen).toBe(false)
|
||||
})
|
||||
|
||||
it('pops in LIFO order', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
|
||||
result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
|
||||
result.current.push('/vault/c.md', 2, stubEntry('/vault/c.md'))
|
||||
})
|
||||
|
||||
expect(result.current.pop()?.path).toBe('/vault/c.md')
|
||||
expect(result.current.pop()?.path).toBe('/vault/b.md')
|
||||
expect(result.current.pop()?.path).toBe('/vault/a.md')
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when popping empty history', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('caps history at 20 entries', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
result.current.push(`/vault/${i}.md`, i, stubEntry(`/vault/${i}.md`))
|
||||
}
|
||||
})
|
||||
|
||||
// Should only have last 20 entries (5-24)
|
||||
const first = result.current.pop()
|
||||
expect(first?.path).toBe('/vault/24.md')
|
||||
|
||||
// Pop remaining 19
|
||||
for (let i = 0; i < 19; i++) {
|
||||
result.current.pop()
|
||||
}
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('deduplicates: closing same path twice keeps only latest entry', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
|
||||
result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
|
||||
result.current.push('/vault/a.md', 2, stubEntry('/vault/a.md')) // close a.md again at different index
|
||||
})
|
||||
|
||||
// a.md should only appear once (the latest), at the top
|
||||
expect(result.current.pop()?.path).toBe('/vault/a.md')
|
||||
expect(result.current.pop()?.path).toBe('/vault/b.md')
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('clear resets the history', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
|
||||
result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
|
||||
})
|
||||
|
||||
act(() => { result.current.clear() })
|
||||
|
||||
expect(result.current.canReopen).toBe(false)
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
})
|
||||
42
src/hooks/useClosedTabHistory.ts
Normal file
42
src/hooks/useClosedTabHistory.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export interface ClosedTabEntry {
|
||||
path: string
|
||||
index: number
|
||||
entry: VaultEntry
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
export function useClosedTabHistory() {
|
||||
const stackRef = useRef<ClosedTabEntry[]>([])
|
||||
|
||||
const push = useCallback((path: string, index: number, entry: VaultEntry) => {
|
||||
const stack = stackRef.current
|
||||
// Remove any existing entry for this path (dedup)
|
||||
const filtered = stack.filter(e => e.path !== path)
|
||||
filtered.push({ path, index, entry })
|
||||
// Cap at MAX_HISTORY
|
||||
if (filtered.length > MAX_HISTORY) {
|
||||
filtered.splice(0, filtered.length - MAX_HISTORY)
|
||||
}
|
||||
stackRef.current = filtered
|
||||
}, [])
|
||||
|
||||
const pop = useCallback((): ClosedTabEntry | null => {
|
||||
const stack = stackRef.current
|
||||
if (stack.length === 0) return null
|
||||
return stack.pop() ?? null
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
stackRef.current = []
|
||||
}, [])
|
||||
|
||||
// Getter so callers see live state without re-render
|
||||
return {
|
||||
push, pop, clear,
|
||||
get canReopen() { return stackRef.current.length > 0 },
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ interface CommandRegistryConfig {
|
||||
modifiedCount: number
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
@@ -199,6 +201,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReindexVault,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
@@ -222,6 +225,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ 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: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
@@ -284,6 +288,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
])
|
||||
}
|
||||
|
||||
222
src/hooks/useDeleteActions.test.ts
Normal file
222
src/hooks/useDeleteActions.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useDeleteActions } from './useDeleteActions'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
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 handleCloseTab: ReturnType<typeof vi.fn>
|
||||
let removeEntry: ReturnType<typeof vi.fn>
|
||||
let setToastMessage: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
handleCloseTab = vi.fn()
|
||||
removeEntry = vi.fn()
|
||||
setToastMessage = vi.fn()
|
||||
mockInvokeFn.mockReset()
|
||||
})
|
||||
|
||||
function renderDeleteActions(entries = [makeEntry('/vault/a.md'), makeEntry('/vault/t.md', true)]) {
|
||||
return renderHook(() =>
|
||||
useDeleteActions({
|
||||
vaultPath: '/vault',
|
||||
entries,
|
||||
handleCloseTab,
|
||||
removeEntry,
|
||||
setToastMessage,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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', () => {
|
||||
it('invokes delete_note, closes tab, removes entry, returns true', async () => {
|
||||
mockInvokeFn.mockResolvedValue(undefined)
|
||||
const { result } = renderDeleteActions()
|
||||
let ok: boolean | undefined
|
||||
await act(async () => {
|
||||
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
|
||||
expect(handleCloseTab).toHaveBeenCalledWith('/vault/a.md')
|
||||
expect(removeEntry).toHaveBeenCalledWith('/vault/a.md')
|
||||
})
|
||||
|
||||
it('shows toast and returns false on failure', async () => {
|
||||
mockInvokeFn.mockRejectedValue(new Error('disk full'))
|
||||
const { result } = renderDeleteActions()
|
||||
let ok: boolean | undefined
|
||||
await act(async () => {
|
||||
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
|
||||
})
|
||||
expect(ok).toBe(false)
|
||||
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to delete'))
|
||||
})
|
||||
})
|
||||
|
||||
// --- handleDeleteNote ---
|
||||
|
||||
describe('handleDeleteNote', () => {
|
||||
it('sets confirmDelete dialog state', async () => {
|
||||
const { result } = renderDeleteActions()
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteNote('/vault/a.md')
|
||||
})
|
||||
expect(result.current.confirmDelete).not.toBeNull()
|
||||
expect(result.current.confirmDelete?.title).toBe('Delete permanently?')
|
||||
})
|
||||
|
||||
it('onConfirm deletes the note and clears dialog', async () => {
|
||||
mockInvokeFn.mockResolvedValue(undefined)
|
||||
const { result } = renderDeleteActions()
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteNote('/vault/a.md')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.confirmDelete!.onConfirm()
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note permanently deleted')
|
||||
})
|
||||
})
|
||||
|
||||
// --- handleBulkDeletePermanently ---
|
||||
|
||||
describe('handleBulkDeletePermanently', () => {
|
||||
it('sets confirmDelete with correct plural title', () => {
|
||||
const { result } = renderDeleteActions()
|
||||
act(() => {
|
||||
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(result.current.confirmDelete?.title).toBe('Delete 2 notes permanently?')
|
||||
})
|
||||
|
||||
it('sets confirmDelete with singular title for one note', () => {
|
||||
const { result } = renderDeleteActions()
|
||||
act(() => {
|
||||
result.current.handleBulkDeletePermanently(['/vault/a.md'])
|
||||
})
|
||||
expect(result.current.confirmDelete?.title).toBe('Delete 1 note permanently?')
|
||||
})
|
||||
|
||||
it('onConfirm deletes all paths and shows toast', async () => {
|
||||
mockInvokeFn.mockResolvedValue(undefined)
|
||||
const { result } = renderDeleteActions()
|
||||
act(() => {
|
||||
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.confirmDelete!.onConfirm()
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
|
||||
})
|
||||
})
|
||||
|
||||
// --- 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(handleCloseTab).toHaveBeenCalledWith('/vault/t1.md')
|
||||
expect(handleCloseTab).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', () => {
|
||||
it('can clear confirmDelete via setConfirmDelete(null)', async () => {
|
||||
const { result } = renderDeleteActions()
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteNote('/vault/a.md')
|
||||
})
|
||||
expect(result.current.confirmDelete).not.toBeNull()
|
||||
act(() => {
|
||||
result.current.setConfirmDelete(null)
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
src/hooks/useDeleteActions.ts
Normal file
105
src/hooks/useDeleteActions.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface ConfirmDeleteState {
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
interface UseDeleteActionsInput {
|
||||
vaultPath: string
|
||||
entries: VaultEntry[]
|
||||
handleCloseTab: (path: string) => void
|
||||
removeEntry: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useDeleteActions({
|
||||
vaultPath,
|
||||
entries,
|
||||
handleCloseTab,
|
||||
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 })
|
||||
else await mockInvoke('delete_note', { path })
|
||||
handleCloseTab(path)
|
||||
removeEntry(path)
|
||||
return true
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to delete note: ${e}`)
|
||||
return false
|
||||
}
|
||||
}, [handleCloseTab, removeEntry, setToastMessage])
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
setConfirmDelete({
|
||||
title: 'Delete permanently?',
|
||||
message: 'This note will be permanently deleted. This cannot be undone.',
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
const ok = await deleteNoteFromDisk(path)
|
||||
if (ok) setToastMessage('Note permanently deleted')
|
||||
},
|
||||
})
|
||||
}, [deleteNoteFromDisk, setToastMessage])
|
||||
|
||||
const handleBulkDeletePermanently = useCallback((paths: string[]) => {
|
||||
const count = paths.length
|
||||
setConfirmDelete({
|
||||
title: `Delete ${count} ${count === 1 ? 'note' : 'notes'} permanently?`,
|
||||
message: `${count === 1 ? 'This note' : `These ${count} notes`} will be permanently deleted. This cannot be undone.`,
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
if (await deleteNoteFromDisk(path)) ok++
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} permanently deleted`)
|
||||
},
|
||||
})
|
||||
}, [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) {
|
||||
handleCloseTab(path)
|
||||
removeEntry(path)
|
||||
}
|
||||
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to empty trash: ${e}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [trashedCount, vaultPath, handleCloseTab, removeEntry, setToastMessage])
|
||||
|
||||
return {
|
||||
confirmDelete,
|
||||
setConfirmDelete,
|
||||
trashedCount,
|
||||
deleteNoteFromDisk,
|
||||
handleDeleteNote,
|
||||
handleBulkDeletePermanently,
|
||||
handleEmptyTrash,
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
function resolveEnterTarget(
|
||||
highlightIndex: number, allFiltered: string[], showCreateOption: boolean, query: string,
|
||||
): string | null {
|
||||
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex]
|
||||
if (showCreateOption && highlightIndex === allFiltered.length) return query.trim()
|
||||
if (query.trim()) return query.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
export function useDropdownKeyboard({
|
||||
highlightIndex, setHighlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel,
|
||||
}: {
|
||||
highlightIndex: number; setHighlightIndex: (i: number) => void; totalOptions: number
|
||||
allFiltered: string[]; showCreateOption: boolean; query: string
|
||||
onSave: (v: string) => void; onCancel: () => void
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollHighlightedIntoView = useCallback((index: number) => {
|
||||
const items = listRef.current?.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
|
||||
items?.[index]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const next = e.key === 'ArrowDown'
|
||||
? (highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0)
|
||||
: (highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1)
|
||||
setHighlightIndex(next)
|
||||
scrollHighlightedIntoView(next)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const target = resolveEnterTarget(highlightIndex, allFiltered, showCreateOption, query)
|
||||
if (target) onSave(target)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
},
|
||||
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, setHighlightIndex, scrollHighlightedIntoView],
|
||||
)
|
||||
|
||||
return { listRef, handleKeyDown }
|
||||
}
|
||||
@@ -13,8 +13,8 @@ interface EditorSaveConfig {
|
||||
setTabs: (fn: SetStateAction<any[]>) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave?: () => void
|
||||
/** Called after content is persisted — used to clear unsaved state. */
|
||||
onNotePersisted?: (path: string) => void
|
||||
/** Called after content is persisted — used to clear unsaved state and live-reload themes. */
|
||||
onNotePersisted?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +41,9 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
if (!pending) return false
|
||||
if (pathFilter && pending.path !== pathFilter) return false
|
||||
await saveNote(pending.path, pending.content)
|
||||
const savedContent = pending.content
|
||||
pendingContentRef.current = null
|
||||
onNotePersisted?.(pending.path)
|
||||
onNotePersisted?.(pending.path, savedContent)
|
||||
return true
|
||||
}, [saveNote, onNotePersisted])
|
||||
|
||||
@@ -53,7 +54,7 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
const saved = await flushPending()
|
||||
if (!saved && unsavedFallback) {
|
||||
await saveNote(unsavedFallback.path, unsavedFallback.content)
|
||||
onNotePersisted?.(unsavedFallback.path)
|
||||
onNotePersisted?.(unsavedFallback.path, unsavedFallback.content)
|
||||
setToastMessage('Saved')
|
||||
onAfterSave()
|
||||
return
|
||||
|
||||
135
src/hooks/useEditorSaveWithLinks.test.ts
Normal file
135
src/hooks/useEditorSaveWithLinks.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
|
||||
const mockHandleContentChange = vi.fn()
|
||||
const mockHandleSave = vi.fn()
|
||||
const mockSavePendingForPath = vi.fn()
|
||||
|
||||
vi.mock('./useEditorSave', () => ({
|
||||
useEditorSave: vi.fn(() => ({
|
||||
handleContentChange: mockHandleContentChange,
|
||||
handleSave: mockHandleSave,
|
||||
savePendingForPath: mockSavePendingForPath,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('useEditorSaveWithLinks', () => {
|
||||
let updateEntry: Mock
|
||||
let setTabs: Mock
|
||||
let setToastMessage: Mock
|
||||
let onAfterSave: Mock
|
||||
|
||||
beforeEach(() => {
|
||||
updateEntry = vi.fn()
|
||||
setTabs = vi.fn()
|
||||
setToastMessage = vi.fn()
|
||||
onAfterSave = vi.fn()
|
||||
mockHandleContentChange.mockClear()
|
||||
mockHandleSave.mockClear()
|
||||
mockSavePendingForPath.mockClear()
|
||||
})
|
||||
|
||||
function renderHookWithLinks() {
|
||||
return renderHook(() =>
|
||||
useEditorSaveWithLinks({
|
||||
updateEntry,
|
||||
setTabs,
|
||||
setToastMessage,
|
||||
onAfterSave,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it('handleContentChange delegates to useEditorSave handleContentChange', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'no links here')
|
||||
})
|
||||
|
||||
expect(mockHandleContentChange).toHaveBeenCalledWith('/note.md', 'no links here')
|
||||
})
|
||||
|
||||
it('handleContentChange calls updateEntry with extracted outgoing links when links change', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[PageA]] and [[PageB]]')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
outgoingLinks: ['PageA', 'PageB'],
|
||||
})
|
||||
})
|
||||
|
||||
it('handleContentChange does NOT call updateEntry again when links have not changed', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'text [[Alpha]] more text')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Same link, different surrounding text
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'different text [[Alpha]] still')
|
||||
})
|
||||
// updateEntry should NOT have been called again — links unchanged
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('handleContentChange calls updateEntry again when links change on subsequent edit', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[Alpha]]')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
outgoingLinks: ['Alpha'],
|
||||
})
|
||||
|
||||
// Now links change
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[Alpha]] and [[Beta]]')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledTimes(2)
|
||||
expect(updateEntry).toHaveBeenLastCalledWith('/note.md', {
|
||||
outgoingLinks: ['Alpha', 'Beta'],
|
||||
})
|
||||
})
|
||||
|
||||
it('handleContentChange calls updateEntry with empty links on first call with no links', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
// First call with no links — prevLinksKeyRef starts as '' and extracted key is also ''
|
||||
// but since they're equal, updateEntry should NOT be called
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'plain text no links')
|
||||
})
|
||||
|
||||
// The initial ref is '' and no-links key is also '' — no change
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles pipe-separated wikilinks (display text syntax)', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[Target|Display Text]]')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
outgoingLinks: ['Target'],
|
||||
})
|
||||
})
|
||||
|
||||
it('spreads all properties from useEditorSave onto the return value', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
// handleSave and savePendingForPath should be passed through from the mock
|
||||
expect(result.current.handleSave).toBeDefined()
|
||||
expect(result.current.savePendingForPath).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks } from '../utils/wikilinks'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
@@ -8,11 +8,15 @@ export function useEditorSaveWithLinks(config: {
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave: () => void
|
||||
onNotePersisted?: (path: string) => void
|
||||
onNotePersisted?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { updateEntry } = config
|
||||
const saveContent = useCallback((path: string, content: string) => {
|
||||
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
|
||||
updateEntry(path, {
|
||||
outgoingLinks: extractOutgoingLinks(content),
|
||||
snippet: extractSnippet(content),
|
||||
wordCount: countWords(content),
|
||||
})
|
||||
}, [updateEntry])
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
|
||||
@@ -158,33 +158,151 @@ describe('replaceTitleInFrontmatter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
|
||||
const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }]
|
||||
|
||||
function makeTab(path: string, title: string) {
|
||||
return {
|
||||
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
|
||||
content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
return {
|
||||
document: docRef.current,
|
||||
get prosemirrorView() { return {} },
|
||||
onMount: (cb: () => void) => { cb(); return () => {} },
|
||||
replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }),
|
||||
insertBlocks: vi.fn(),
|
||||
blocksToMarkdownLossy: vi.fn(() => ''),
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
_docRef: docRef,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEditorTabSwap raw mode sync', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
// Initial load — parses and caches blocks
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Enter raw mode
|
||||
rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Simulate raw editing: tab content was updated externally
|
||||
const updatedTab = {
|
||||
...tabA,
|
||||
content: '---\ntitle: Updated Title\n---\n\n# Updated Title\n\nNew body content.',
|
||||
}
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
// Exit raw mode with updated content
|
||||
rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Verify re-parse happened with updated body content
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Updated Title'),
|
||||
)
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not skip swap when rawMode is on (editor hidden)', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
// Enter raw mode and update content
|
||||
const updatedTab = { ...tabA, content: '---\ntitle: Changed\n---\n\n# Changed\n\nEdited.' }
|
||||
rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// While in raw mode, the editor should NOT be updated
|
||||
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves content through multiple BlockNote→raw→BlockNote cycles', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Cycle 1: raw mode on → edit → raw mode off
|
||||
rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
const edit1 = { ...tabA, content: '---\ntitle: Edit 1\n---\n\n# Edit 1\n\nFirst edit.' }
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Edit 1'),
|
||||
)
|
||||
|
||||
// Cycle 2: raw mode on → edit → raw mode off
|
||||
rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
const edit2 = { ...tabA, content: '---\ntitle: Edit 2\n---\n\n# Edit 2\n\nSecond edit.' }
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
rerender({ tabs: [edit2], activeTabPath: 'a.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Edit 2'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEditorTabSwap scroll position', () => {
|
||||
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
|
||||
const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }]
|
||||
|
||||
function makeTab(path: string, title: string) {
|
||||
return {
|
||||
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
|
||||
content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
const mountCallbacks: Array<() => void> = []
|
||||
return {
|
||||
document: docRef.current,
|
||||
get prosemirrorView() { return {} },
|
||||
onMount: (cb: () => void) => { mountCallbacks.push(cb); return () => {} },
|
||||
replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }),
|
||||
insertBlocks: vi.fn(),
|
||||
blocksToMarkdownLossy: vi.fn(() => ''),
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
// Make document getter dynamic
|
||||
_docRef: docRef,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
|
||||
import { compactMarkdown } from '../utils/compact-markdown'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -18,6 +19,8 @@ interface UseEditorTabSwapOptions {
|
||||
onH1Change?: (h1Text: string | null) => void
|
||||
/** When .current is false, handleEditorChange won't update frontmatter title from H1. */
|
||||
syncActiveRef?: React.MutableRefObject<boolean>
|
||||
/** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */
|
||||
rawMode?: boolean
|
||||
}
|
||||
|
||||
/** Strip the YAML frontmatter from raw file content, returning the body
|
||||
@@ -60,13 +63,17 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
|
||||
*
|
||||
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
|
||||
*/
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) {
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef, rawMode }: UseEditorTabSwapOptions) {
|
||||
// Cache parsed blocks + scroll position per tab path for instant switching
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
const tabCacheRef = useRef<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
const prevRawModeRef = useRef(!!rawMode)
|
||||
// Guard: prevents a subsequent effect run from re-caching stale blocks
|
||||
// while a raw-mode swap is still pending in a microtask/pendingSwap.
|
||||
const rawSwapPendingRef = useRef(false)
|
||||
|
||||
// Suppress onChange during programmatic content swaps (tab switching / initial load)
|
||||
const suppressChangeRef = useRef(false)
|
||||
@@ -111,7 +118,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
// Convert blocks → markdown, restoring wikilinks first
|
||||
const blocks = editor.document
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
|
||||
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
@@ -136,6 +143,15 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
|
||||
// Detect raw mode transition: true → false means we need to re-parse
|
||||
// from tab.content since the cached blocks are stale.
|
||||
const rawModeJustEnded = prevRawModeRef.current && !rawMode
|
||||
prevRawModeRef.current = !!rawMode
|
||||
|
||||
// While raw mode is active the BlockNote editor is hidden — skip all
|
||||
// swap logic to avoid touching the invisible editor.
|
||||
if (rawMode) return
|
||||
|
||||
// Save current editor state + scroll position for the tab we're leaving
|
||||
if (prevPath && pathChanged && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
@@ -146,19 +162,31 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
}
|
||||
prevActivePathRef.current = activeTabPath
|
||||
|
||||
// When tab content updates but the active tab stays the same (e.g. after
|
||||
// Cmd+S save), refresh the cache with the current editor blocks so a later
|
||||
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
|
||||
// the editor already shows the user's edits.
|
||||
if (!pathChanged) {
|
||||
if (activeTabPath && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
cache.set(activeTabPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: scrollEl?.scrollTop ?? 0,
|
||||
})
|
||||
if (rawModeJustEnded && activeTabPath) {
|
||||
// Raw mode just ended — invalidate stale cached blocks so we
|
||||
// re-parse from the latest tab.content below.
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
} else {
|
||||
// While a raw-mode swap is pending (scheduled via microtask), a second
|
||||
// effect run can fire due to the tabs prop updating. Skip re-caching
|
||||
// stale editor.document to avoid poisoning the cache before doSwap runs.
|
||||
if (rawSwapPendingRef.current) return
|
||||
|
||||
// When tab content updates but the active tab stays the same (e.g. after
|
||||
// Cmd+S save), refresh the cache with the current editor blocks so a later
|
||||
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
|
||||
// the editor already shows the user's edits.
|
||||
if (activeTabPath && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
cache.set(activeTabPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: scrollEl?.scrollTop ?? 0,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeTabPath) return
|
||||
@@ -201,6 +229,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
const doSwap = () => {
|
||||
// Guard: bail if user switched tabs since this swap was scheduled
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
rawSwapPendingRef.current = false
|
||||
|
||||
if (cache.has(targetPath)) {
|
||||
const cached = cache.get(targetPath)!
|
||||
@@ -267,7 +296,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
} else {
|
||||
pendingSwapRef.current = doSwap
|
||||
}
|
||||
}, [activeTabPath, tabs, editor])
|
||||
}, [activeTabPath, tabs, editor, rawMode])
|
||||
|
||||
// Clean up cache entries when tabs are closed
|
||||
const tabPathsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { uploadImageFile, useImageDrop } from './useImageDrop'
|
||||
import { createRef } from 'react'
|
||||
|
||||
let tauriMode = false
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: () => tauriMode,
|
||||
}))
|
||||
|
||||
type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } }
|
||||
type DragDropCallback = (event: DragDropEvent) => void
|
||||
let capturedDragDropHandler: DragDropCallback | null = null
|
||||
|
||||
vi.mock('@tauri-apps/api/webview', () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: vi.fn((cb: DragDropCallback) => {
|
||||
capturedDragDropHandler = cb
|
||||
return Promise.resolve(() => { capturedDragDropHandler = null })
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
// JSDOM lacks DragEvent and File.arrayBuffer — polyfill for tests
|
||||
@@ -68,10 +83,7 @@ describe('uploadImageFile', () => {
|
||||
})
|
||||
|
||||
it('passes file to Tauri save_image in Tauri mode', async () => {
|
||||
const mockTauri = await import('../mock-tauri')
|
||||
const originalIsTauri = mockTauri.isTauri
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(mockTauri as any).isTauri = () => true
|
||||
tauriMode = true
|
||||
|
||||
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-test.png')
|
||||
@@ -88,8 +100,7 @@ describe('uploadImageFile', () => {
|
||||
})
|
||||
expect(url).toBe('asset://localhost/vault/attachments/123-test.png')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(mockTauri as any).isTauri = originalIsTauri
|
||||
tauriMode = false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,3 +179,75 @@ describe('useImageDrop', () => {
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useImageDrop — Tauri native drag-drop', () => {
|
||||
let container: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
tauriMode = true
|
||||
capturedDragDropHandler = null
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
tauriMode = false
|
||||
capturedDragDropHandler = null
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderImageDropTauri(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
|
||||
const ref = createRef<HTMLDivElement>()
|
||||
Object.defineProperty(ref, 'current', { value: container, writable: true })
|
||||
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
|
||||
}
|
||||
|
||||
it('does not set isDragOver on Tauri over event (internal drags are indistinguishable)', async () => {
|
||||
const { result } = renderImageDropTauri()
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({ payload: { type: 'over', paths: [], position: { x: 100, y: 100 } } })
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isDragOver on Tauri drop event', async () => {
|
||||
const onImageUrl = vi.fn()
|
||||
const { result } = renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
// Set isDragOver via HTML5 dragover (simulates real OS file drag)
|
||||
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({
|
||||
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isDragOver on Tauri cancel event', async () => {
|
||||
const { result } = renderImageDropTauri()
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
// Set isDragOver via HTML5 dragover first
|
||||
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({ payload: { type: 'cancel', paths: [], position: { x: 0, y: 0 } } })
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,8 +109,9 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr
|
||||
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
|
||||
const payload = event.payload
|
||||
if (payload.type === 'over') {
|
||||
// Tauri 'over' events don't include paths — show overlay for any drag
|
||||
setIsDragOver(true)
|
||||
// Tauri 'over' events don't include paths and can't distinguish
|
||||
// OS file drags from internal drags (tabs, blocks). Let the HTML5
|
||||
// dragover handler drive isDragOver — it checks hasImageFiles().
|
||||
} else if (payload.type === 'drop') {
|
||||
setIsDragOver(false)
|
||||
const imagePaths = payload.paths.filter(isImagePath)
|
||||
|
||||
@@ -36,6 +36,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onReopenClosedTab: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -313,6 +314,13 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onReloadVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// File menu: reopen closed tab
|
||||
it('file-reopen-closed-tab triggers reopen closed tab', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('file-reopen-closed-tab', h)
|
||||
expect(h.onReopenClosedTab).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
|
||||
@@ -35,9 +35,11 @@ export interface MenuEventHandlers {
|
||||
onResolveConflicts?: () => void
|
||||
onViewChanges?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
activeTabPath: string | null
|
||||
@@ -81,6 +83,8 @@ type OptionalHandler =
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCreateTheme' | 'onRestoreDefaultThemes'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onEmptyTrash'
|
||||
| 'onReopenClosedTab'
|
||||
|
||||
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'view-go-back': 'onGoBack',
|
||||
@@ -102,6 +106,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-reindex': 'onReindexVault',
|
||||
'vault-reload': 'onReloadVault',
|
||||
'vault-repair': 'onRepairVault',
|
||||
'note-empty-trash': 'onEmptyTrash',
|
||||
'file-reopen-closed-tab': 'onReopenClosedTab',
|
||||
}
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
slugify,
|
||||
needsRenameOnSave,
|
||||
buildNewEntry,
|
||||
generateUntitledName,
|
||||
entryMatchesTarget,
|
||||
@@ -77,13 +78,37 @@ describe('slugify', () => {
|
||||
expect(slugify('--hello--')).toBe('hello')
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
it('handles empty string with fallback', () => {
|
||||
expect(slugify('')).toBe('untitled')
|
||||
})
|
||||
|
||||
it('collapses multiple separators into one hyphen', () => {
|
||||
expect(slugify('hello world---foo')).toBe('hello-world-foo')
|
||||
})
|
||||
|
||||
it('returns fallback for strings with only special characters', () => {
|
||||
// slugify('+++') should not return empty string — it causes invalid paths
|
||||
expect(slugify('+++')).not.toBe('')
|
||||
expect(slugify('!!!')).not.toBe('')
|
||||
expect(slugify('---')).not.toBe('')
|
||||
expect(slugify('@#$')).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('needsRenameOnSave', () => {
|
||||
it('returns true when filename does not match title slug', () => {
|
||||
expect(needsRenameOnSave('My New Note', 'untitled-note.md')).toBe(true)
|
||||
expect(needsRenameOnSave('Run good ads for newsletter', 'untitled-note-9.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when filename matches title slug', () => {
|
||||
expect(needsRenameOnSave('My Note', 'my-note.md')).toBe(false)
|
||||
expect(needsRenameOnSave('Hello World', 'hello-world.md')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for untitled note with matching slug', () => {
|
||||
expect(needsRenameOnSave('Untitled note', 'untitled-note.md')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewEntry', () => {
|
||||
@@ -157,32 +182,37 @@ describe('generateUntitledName', () => {
|
||||
describe('entryMatchesTarget', () => {
|
||||
it('matches by exact title (case-insensitive)', () => {
|
||||
const entry = makeEntry({ title: 'My Project' })
|
||||
expect(entryMatchesTarget(entry, 'my project', 'my project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'my project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by alias', () => {
|
||||
const entry = makeEntry({ aliases: ['MP', 'TheProject'] })
|
||||
expect(entryMatchesTarget(entry, 'mp', 'mp')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by path stem (relative to Laputa)', () => {
|
||||
it('matches by path suffix (type/slug)', () => {
|
||||
const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' })
|
||||
expect(entryMatchesTarget(entry, 'project/my-project', 'project/my-project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by filename stem', () => {
|
||||
const entry = makeEntry({ filename: 'my-project.md' })
|
||||
expect(entryMatchesTarget(entry, 'my-project', 'my-project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches when target as words matches title', () => {
|
||||
const entry = makeEntry({ title: 'my project' })
|
||||
expect(entryMatchesTarget(entry, 'project/my-project', 'my project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nothing matches', () => {
|
||||
const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' })
|
||||
expect(entryMatchesTarget(entry, 'nonexistent', 'nonexistent')).toBe(false)
|
||||
expect(entryMatchesTarget(entry, 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles pipe syntax targets', () => {
|
||||
const entry = makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha' })
|
||||
expect(entryMatchesTarget(entry, 'project/alpha|Alpha Project')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,6 +300,20 @@ describe('resolveNewNote', () => {
|
||||
expect(entry.path).toBe('/other/vault/note/test.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
|
||||
it('produces a valid path for custom types with special characters', () => {
|
||||
const { entry } = resolveNewNote('My Note', 'Q&A', '/vault')
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.path).toMatch(/\.md$/)
|
||||
expect(entry.filename).not.toBe('.md')
|
||||
})
|
||||
|
||||
it('produces a valid path when type is all special characters', () => {
|
||||
const { entry } = resolveNewNote('My Note', '+++', '/vault')
|
||||
// folder should not be empty, path should not have double slashes
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.path).toMatch(/\.md$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
@@ -612,6 +656,34 @@ describe('useNoteActions hook', () => {
|
||||
expect(tabContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Q&A')
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const [entry] = addEntry.mock.calls[0]
|
||||
expect(entry.isA).toBe('Q&A')
|
||||
expect(entry.path).not.toContain('//')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('+++')
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const [entry] = addEntry.mock.calls[0]
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.filename).not.toBe('.md')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
@@ -938,6 +1010,42 @@ describe('useNoteActions hook', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves note content after type change — never loads another note (regression)', async () => {
|
||||
// The mock updateMockFrontmatter returns '---\nupdated: true\n---\n' —
|
||||
// this represents the note's own content after the frontmatter update.
|
||||
const frontmatterUpdatedContent = '---\nupdated: true\n---\n'
|
||||
const wrongContent = '---\ntype: Project\n---\n# Feedback for Laputa\n\nCompletely different note.\n'
|
||||
|
||||
const entry = makeEntry({ path: '/test/vault/note/migrate-newsletter.md', filename: 'migrate-newsletter.md', title: 'Migrate newsletter to Beehiiv', isA: 'Note' })
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/migrate-newsletter.md', updated_links: 0, moved: true }
|
||||
// Simulate the bug: get_note_content returns a DIFFERENT note's content
|
||||
// (e.g. path collision, stale cache, or filesystem race)
|
||||
if (cmd === 'get_note_content') return wrongContent
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
// Open the tab first so we have a tab to check
|
||||
act(() => { result.current.openTabWithContent(entry, '---\ntype: Note\n---\n# Migrate\n') })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/note/migrate-newsletter.md', 'type', 'Project')
|
||||
})
|
||||
|
||||
// The tab content must be the note's OWN content (from the frontmatter update),
|
||||
// NEVER the content of a different note loaded via get_note_content.
|
||||
const tab = result.current.tabs.find(t => t.entry.path === '/test/vault/project/migrate-newsletter.md')
|
||||
expect(tab).toBeDefined()
|
||||
expect(tab!.content).toBe(frontmatterUpdatedContent)
|
||||
expect(tab!.content).not.toBe(wrongContent)
|
||||
})
|
||||
|
||||
it('does not move when value is empty or null-like', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('')
|
||||
@@ -951,4 +1059,113 @@ describe('useNoteActions hook', () => {
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename note updates wikilinks', () => {
|
||||
it('handleRenameNote passes entry title as old_title to rename_note', async () => {
|
||||
const entry = makeEntry({
|
||||
path: '/test/vault/note/weekly-review.md',
|
||||
filename: 'weekly-review.md',
|
||||
title: 'Weekly Review',
|
||||
})
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/test/vault/note/sprint-retro.md', updated_files: 2 }
|
||||
if (cmd === 'get_note_content') return '---\nIs A: Note\n---\n# Sprint Retro\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote(
|
||||
'/test/vault/note/weekly-review.md',
|
||||
'Sprint Retro',
|
||||
'/test/vault',
|
||||
replaceEntry,
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
||||
vault_path: '/test/vault',
|
||||
old_path: '/test/vault/note/weekly-review.md',
|
||||
new_title: 'Sprint Retro',
|
||||
old_title: 'Weekly Review',
|
||||
}))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
|
||||
})
|
||||
|
||||
it('handleRenameNote passes null old_title when entry not found', async () => {
|
||||
const config = makeConfig([])
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/test/vault/note/new.md', updated_files: 0 }
|
||||
if (cmd === 'get_note_content') return '# New\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote(
|
||||
'/test/vault/note/old.md', 'New', '/test/vault', vi.fn(),
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
||||
old_title: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter triggers rename when title key is changed', async () => {
|
||||
const entry = makeEntry({
|
||||
path: '/test/vault/note/old-name.md',
|
||||
filename: 'old-name.md',
|
||||
title: 'Old Name',
|
||||
})
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/test/vault/note/new-name.md', updated_files: 1 }
|
||||
if (cmd === 'get_note_content') return '---\ntitle: New Name\n---\n# New Name\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
// Open a tab for the entry so the rename can find it via tabsRef
|
||||
await act(async () => { result.current.handleSelectNote(entry) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/note/old-name.md', 'title', 'New Name')
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
||||
old_path: '/test/vault/note/old-name.md',
|
||||
new_title: 'New Name',
|
||||
old_title: 'Old Name',
|
||||
}))
|
||||
expect(replaceEntry).toHaveBeenCalledWith(
|
||||
'/test/vault/note/old-name.md',
|
||||
expect.objectContaining({ path: '/test/vault/note/new-name.md', title: 'New Name' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter does not trigger rename for non-title keys', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('')
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
||||
})
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith('rename_note', expect.anything())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
|
||||
interface NewEntryParams {
|
||||
path: string
|
||||
@@ -60,15 +61,21 @@ function isTypeKey(key: string): boolean {
|
||||
return k === 'type' || k === 'is_a'
|
||||
}
|
||||
|
||||
/** Check if a frontmatter key represents the note title. */
|
||||
function isTitleKey(key: string): boolean {
|
||||
return key.toLowerCase().replace(/\s+/g, '_') === 'title'
|
||||
}
|
||||
|
||||
async function performRename(
|
||||
path: string,
|
||||
newTitle: string,
|
||||
vaultPath: string,
|
||||
oldTitle?: string,
|
||||
): Promise<RenameResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle })
|
||||
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null })
|
||||
}
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle })
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null })
|
||||
}
|
||||
|
||||
function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
|
||||
@@ -100,7 +107,13 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const result = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
return result || 'untitled'
|
||||
}
|
||||
|
||||
/** Check if a note's filename doesn't match the slug of its current title. */
|
||||
export function needsRenameOnSave(title: string, filename: string): boolean {
|
||||
return `${slugify(title)}.md` !== filename
|
||||
}
|
||||
|
||||
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
|
||||
@@ -117,14 +130,8 @@ export function generateUntitledName(entries: VaultEntry[], type: string, pendin
|
||||
return title
|
||||
}
|
||||
|
||||
export function entryMatchesTarget(e: VaultEntry, targetLower: string, targetAsWords: string): boolean {
|
||||
if (e.title.toLowerCase() === targetLower) return true
|
||||
if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true
|
||||
const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
if (pathStem.toLowerCase() === targetLower) return true
|
||||
const fileStem = e.filename.replace(/\.md$/, '')
|
||||
if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true
|
||||
return e.title.toLowerCase() === targetAsWords
|
||||
export function entryMatchesTarget(e: VaultEntry, target: string): boolean {
|
||||
return resolveEntry([e], target) === e
|
||||
}
|
||||
|
||||
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
|
||||
@@ -256,9 +263,7 @@ function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => voi
|
||||
}
|
||||
|
||||
function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
|
||||
const targetLower = target.toLowerCase()
|
||||
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
|
||||
return entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords))
|
||||
return resolveEntry(entries, target)
|
||||
}
|
||||
|
||||
/** Navigate to a wikilink target, logging a warning if not found. */
|
||||
@@ -392,18 +397,42 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
try {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
setToastMessage('Failed to create note')
|
||||
}
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Create a note with the given title, open it in a tab, and persist to disk.
|
||||
* Returns true on success, false on failure (shows toast on error). */
|
||||
const handleCreateNoteForRelationship = useCallback(async (title: string): Promise<boolean> => {
|
||||
const template = resolveTemplate(entries, 'Note')
|
||||
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
try {
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
config.onNewNotePersisted?.()
|
||||
return true
|
||||
} catch {
|
||||
handleCloseTab(resolved.entry.path)
|
||||
removeEntry(resolved.entry.path)
|
||||
setToastMessage('Failed to create note — disk write error')
|
||||
return false
|
||||
}
|
||||
}, [entries, openTabWithContent, addEntry, handleCloseTab, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Close tab and discard entry+unsaved state if the note was never persisted. */
|
||||
const handleCloseTabWithCleanup = useCallback((path: string) => {
|
||||
@@ -439,9 +468,9 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const result = await performRename(path, newTitle, vaultPath)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
const result = await performRename(path, newTitle, vaultPath, entry?.title)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
|
||||
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path)
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
|
||||
@@ -461,25 +490,49 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleNavigateWikilink,
|
||||
handleCreateNote,
|
||||
handleCreateNoteImmediate,
|
||||
handleCreateNoteForRelationship,
|
||||
handleOpenDailyNote,
|
||||
handleCreateType,
|
||||
createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
await runFrontmatterOp('update', path, key, value)
|
||||
if (isTitleKey(key) && typeof value === 'string' && value !== '') {
|
||||
try {
|
||||
const oldTitle = tabsRef.current.find(t => t.entry.path === path)?.entry.title
|
||||
const result = await performRename(path, value, config.vaultPath, oldTitle)
|
||||
if (result.new_path !== path) {
|
||||
const newFilename = result.new_path.split('/').pop() ?? ''
|
||||
config.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: value } as Partial<VaultEntry> & { path: string })
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: value }, content: newContent }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path)
|
||||
await reloadTabsAfterRename(otherTabPaths, updateTabContent)
|
||||
}
|
||||
setToastMessage(renameToastMessage(result.updated_files))
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
if (isTypeKey(key) && typeof value === 'string' && value !== '') {
|
||||
try {
|
||||
const result = await performMoveToTypeFolder(config.vaultPath, path, value)
|
||||
if (result.moved) {
|
||||
const entry = entries.find(e => e.path === path)
|
||||
if (entry) {
|
||||
const newFilename = result.new_path.split('/').pop() ?? entry.filename
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename })
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
}
|
||||
const newFilename = result.new_path.split('/').pop() ?? ''
|
||||
// Update the vault entry with the new path. Only pass the changed
|
||||
// fields — avoid spreading a stale closure entry which would revert
|
||||
// the isA update that runFrontmatterOp already applied.
|
||||
config.replaceEntry?.(path, { path: result.new_path, filename: newFilename } as Partial<VaultEntry> & { path: string })
|
||||
// Preserve the tab content already set by runFrontmatterOp.
|
||||
// Re-reading from disk via loadNoteContent is unnecessary (the move
|
||||
// does not change content) and dangerous: if the path collides or a
|
||||
// stale cache intervenes it could return a different note's content.
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: t.content }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
const folder = result.new_path.split('/').slice(-2, -1)[0] ?? ''
|
||||
setToastMessage(`Note moved to ${folder}/`)
|
||||
}
|
||||
@@ -487,7 +540,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
console.error('Failed to move note to type folder:', err)
|
||||
}
|
||||
}
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]),
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleRenameNote,
|
||||
|
||||
@@ -84,4 +84,32 @@ describe('useRawMode', () => {
|
||||
// Cannot activate raw mode without an active tab path
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onBeforeRawEnd when deactivating raw mode', async () => {
|
||||
const onBeforeRawEnd = vi.fn()
|
||||
const { result } = renderHook(
|
||||
({ path }) => useRawMode({ activeTabPath: path, onFlushPending, onBeforeRawEnd }),
|
||||
{ initialProps: { path: '/note.md' } },
|
||||
)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onBeforeRawEnd).toHaveBeenCalledOnce()
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('does not call onBeforeRawEnd when activating raw mode', async () => {
|
||||
const onBeforeRawEnd = vi.fn()
|
||||
const { result } = renderHook(
|
||||
({ path }) => useRawMode({ activeTabPath: path, onFlushPending, onBeforeRawEnd }),
|
||||
{ initialProps: { path: '/note.md' } },
|
||||
)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onBeforeRawEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ interface UseRawModeParams {
|
||||
activeTabPath: string | null
|
||||
/** Flush pending WYSIWYG edits to disk before entering raw mode. */
|
||||
onFlushPending?: () => Promise<boolean>
|
||||
/** Called synchronously before raw mode is deactivated, so the caller can
|
||||
* flush any debounced raw-editor content into tab state. */
|
||||
onBeforeRawEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11,19 +14,20 @@ interface UseRawModeParams {
|
||||
* Raw mode is automatically inactive when the active tab changes,
|
||||
* because rawMode is derived from whether the stored path matches the current tab.
|
||||
*/
|
||||
export function useRawMode({ activeTabPath, onFlushPending }: UseRawModeParams) {
|
||||
export function useRawMode({ activeTabPath, onFlushPending, onBeforeRawEnd }: UseRawModeParams) {
|
||||
// Track which path has raw mode active — automatically deactivates on tab switch
|
||||
const [rawActivePath, setRawActivePath] = useState<string | null>(null)
|
||||
const rawMode = rawActivePath !== null && rawActivePath === activeTabPath
|
||||
|
||||
const handleToggleRaw = useCallback(async () => {
|
||||
if (rawMode) {
|
||||
onBeforeRawEnd?.()
|
||||
setRawActivePath(null)
|
||||
} else {
|
||||
await onFlushPending?.()
|
||||
setRawActivePath(activeTabPath)
|
||||
}
|
||||
}, [rawMode, activeTabPath, onFlushPending])
|
||||
}, [rawMode, activeTabPath, onFlushPending, onBeforeRawEnd])
|
||||
|
||||
return { rawMode, handleToggleRaw }
|
||||
}
|
||||
|
||||
@@ -439,6 +439,111 @@ describe('useTabManagement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('closed tab history', () => {
|
||||
it('handleCloseTab records the closed tab in history', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
|
||||
act(() => { result.current.handleCloseTab('/vault/a.md') })
|
||||
|
||||
expect(result.current.closedTabHistory.canReopen).toBe(true)
|
||||
})
|
||||
|
||||
it('handleReopenClosedTab reopens the last closed tab', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
act(() => { result.current.handleCloseTab('/vault/b.md') })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReopenClosedTab()
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(2)
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('close 3 tabs then reopen all 3 in correct LIFO order', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
|
||||
})
|
||||
|
||||
// Close C, B, A
|
||||
act(() => { result.current.handleCloseTab('/vault/c.md') })
|
||||
act(() => { result.current.handleCloseTab('/vault/b.md') })
|
||||
act(() => { result.current.handleCloseTab('/vault/a.md') })
|
||||
|
||||
expect(result.current.tabs).toHaveLength(0)
|
||||
|
||||
// Reopen: should get A first (last closed), then B, then C
|
||||
await act(async () => { await result.current.handleReopenClosedTab() })
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
|
||||
await act(async () => { await result.current.handleReopenClosedTab() })
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
|
||||
await act(async () => { await result.current.handleReopenClosedTab() })
|
||||
expect(result.current.activeTabPath).toBe('/vault/c.md')
|
||||
|
||||
expect(result.current.tabs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does nothing when history is empty', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReopenClosedTab()
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(0)
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
})
|
||||
|
||||
it('does not duplicate tab if note is already open', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
// Close B
|
||||
act(() => { result.current.handleCloseTab('/vault/b.md') })
|
||||
|
||||
// Manually reopen B via handleSelectNote
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
// Now try to reopen from history — B is already open, should just switch
|
||||
await act(async () => {
|
||||
await result.current.handleReopenClosedTab()
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(2)
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
it('only activates the last note when switching rapidly', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useClosedTabHistory } from './useClosedTabHistory'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -137,6 +138,7 @@ export function useTabManagement() {
|
||||
const tabsRef = useRef(tabs)
|
||||
useEffect(() => { tabsRef.current = tabs })
|
||||
const handleCloseTabRef = useRef<(path: string) => void>(() => {})
|
||||
const closedTabHistory = useClosedTabHistory()
|
||||
|
||||
// Sequence counter for rapid-switch safety: only the latest navigation wins.
|
||||
// Prevents stale content from an earlier click appearing after a later click.
|
||||
@@ -151,11 +153,13 @@ export function useTabManagement() {
|
||||
|
||||
const handleCloseTab = useCallback((path: string) => {
|
||||
setTabs((prev) => {
|
||||
const idx = prev.findIndex((t) => t.entry.path === path)
|
||||
if (idx !== -1) closedTabHistory.push(path, idx, prev[idx].entry)
|
||||
const next = prev.filter((t) => t.entry.path !== path)
|
||||
if (path === activeTabPathRef.current) { setActiveTabPath(resolveNextActiveTab(prev, path)) }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}, [closedTabHistory])
|
||||
useEffect(() => { handleCloseTabRef.current = handleCloseTab })
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, [])
|
||||
@@ -180,6 +184,18 @@ export function useTabManagement() {
|
||||
if (navSeqRef.current === seq) setActiveTabPath(entry.path)
|
||||
}, [handleSelectNote])
|
||||
|
||||
const handleReopenClosedTab = useCallback(async () => {
|
||||
const closed = closedTabHistory.pop()
|
||||
if (!closed) return
|
||||
// If tab is already open, just switch to it
|
||||
if (isTabOpen(tabsRef.current, closed.path)) {
|
||||
setActiveTabPath(closed.path)
|
||||
return
|
||||
}
|
||||
// Reopen using the stored VaultEntry — loads fresh content from disk
|
||||
await handleSelectNote(closed.entry)
|
||||
}, [closedTabHistory, handleSelectNote])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
setTabs([])
|
||||
setActiveTabPath(null)
|
||||
@@ -209,6 +225,8 @@ export function useTabManagement() {
|
||||
handleSwitchTab,
|
||||
handleReorderTabs,
|
||||
handleReplaceActiveTab,
|
||||
handleReopenClosedTab,
|
||||
closeAllTabs,
|
||||
closedTabHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,55 @@ describe('useThemeManager', () => {
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('notifyThemeSaved updates CSS vars when active theme is saved', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
|
||||
const updatedContent = `---
|
||||
type: Theme
|
||||
Description: Light theme
|
||||
background: "#1a1a2e"
|
||||
foreground: "#e0e0e0"
|
||||
primary: "#155DFF"
|
||||
sidebar: "#2a2a3e"
|
||||
text-primary: "#e0e0e0"
|
||||
---
|
||||
|
||||
# Default Theme
|
||||
`
|
||||
act(() => {
|
||||
result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#1a1a2e')
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#2a2a3e')
|
||||
})
|
||||
|
||||
it('notifyThemeSaved is a no-op for non-active theme path', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
|
||||
act(() => {
|
||||
result.current.notifyThemeSaved(THEME_PATH_DARK, DARK_THEME_CONTENT)
|
||||
})
|
||||
|
||||
// Background should still be the default theme's white, not dark
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
it('calls ensure_vault_themes on mount with vaultPath', async () => {
|
||||
renderHook(() => useThemeManager('/vault', entries, allContent))
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -124,6 +124,8 @@ export interface ThemeManager {
|
||||
reloadThemes: () => Promise<void>
|
||||
/** Update a single frontmatter property on the active theme note. */
|
||||
updateThemeProperty: (key: string, value: string) => Promise<void>
|
||||
/** Notify that the active theme note was saved with new content (live-reload on Cmd+S). */
|
||||
notifyThemeSaved: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/** Manages loading and persisting the active theme path from vault settings. */
|
||||
@@ -275,6 +277,10 @@ export function useThemeManager(
|
||||
|
||||
const reloadThemes = useCallback(async () => { await reload() }, [reload])
|
||||
|
||||
const notifyThemeSaved = useCallback((path: string, content: string) => {
|
||||
if (path === activeThemeId) setCachedThemeContent(content)
|
||||
}, [activeThemeId])
|
||||
|
||||
const updateThemeProperty = useCallback(async (key: string, value: string) => {
|
||||
if (!activeThemeId) return
|
||||
try {
|
||||
@@ -290,6 +296,6 @@ export function useThemeManager(
|
||||
return {
|
||||
themes, activeThemeId, activeTheme,
|
||||
activeThemeContent: cachedThemeContent,
|
||||
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty,
|
||||
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty, notifyThemeSaved,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
--accent-teal-light: rgba(49, 151, 149, 0.1);
|
||||
--accent-pink: #D53F8C;
|
||||
--accent-pink-light: rgba(213, 63, 140, 0.1);
|
||||
--accent-gray: #718096;
|
||||
--accent-gray-light: rgba(113, 128, 150, 0.1);
|
||||
--border-primary: #E9E9E7;
|
||||
--border-subtle: #E9E9E7;
|
||||
--border-input: #E9E9E7;
|
||||
|
||||
@@ -735,19 +735,147 @@ Essential reading for anyone building distributed systems. Covers replication, p
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/default.md': `---
|
||||
type: Theme
|
||||
title: Default
|
||||
primary: "#155DFF"
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
sidebar: "#F7F6F3"
|
||||
border: "#E9E9E7"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#EBEBEA"
|
||||
secondary-foreground: "#37352F"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#9B9A97"
|
||||
accent: "#F0F7FF"
|
||||
accent-foreground: "#0A3B8F"
|
||||
muted-foreground: "#787774"
|
||||
accent: "#EBEBEA"
|
||||
accent-foreground: "#37352F"
|
||||
destructive: "#E03E3E"
|
||||
border: "#E9E9E7"
|
||||
input: "#E9E9E7"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
sidebar-foreground: "#37352F"
|
||||
sidebar-border: "#E9E9E7"
|
||||
sidebar-accent: "#EBEBEA"
|
||||
text-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-tertiary: "#B4B4B4"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-card: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#E03E3E"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF14"
|
||||
accent-green-light: "#00B38B14"
|
||||
accent-purple-light: "#A932FF14"
|
||||
accent-red-light: "#E03E3E14"
|
||||
accent-yellow-light: "#F0B10014"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
font-size-base: 14px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#177bfd"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# Default
|
||||
@@ -756,19 +884,147 @@ Light theme with warm, paper-like tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/dark.md': `---
|
||||
type: Theme
|
||||
title: Dark
|
||||
primary: "#155DFF"
|
||||
Description: Dark variant with deep navy tones
|
||||
background: "#0f0f1a"
|
||||
foreground: "#e0e0e0"
|
||||
sidebar: "#1a1a2e"
|
||||
border: "#2a2a4a"
|
||||
card: "#16162a"
|
||||
popover: "#1e1e3a"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#2a2a4a"
|
||||
secondary-foreground: "#e0e0e0"
|
||||
muted: "#1e1e3a"
|
||||
muted-foreground: "#6b6b8a"
|
||||
accent: "#1a2a4a"
|
||||
accent-foreground: "#8ab4ff"
|
||||
muted-foreground: "#888888"
|
||||
accent: "#2a2a4a"
|
||||
accent-foreground: "#e0e0e0"
|
||||
destructive: "#f44336"
|
||||
border: "#2a2a4a"
|
||||
input: "#2a2a4a"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#1a1a2e"
|
||||
sidebar-foreground: "#e0e0e0"
|
||||
sidebar-border: "#2a2a4a"
|
||||
sidebar-accent: "#2a2a4a"
|
||||
text-primary: "#e0e0e0"
|
||||
text-secondary: "#888888"
|
||||
text-tertiary: "#666666"
|
||||
text-muted: "#666666"
|
||||
text-heading: "#e0e0e0"
|
||||
bg-primary: "#0f0f1a"
|
||||
bg-card: "#16162a"
|
||||
bg-sidebar: "#1a1a2e"
|
||||
bg-hover: "#2a2a4a"
|
||||
bg-hover-subtle: "#1e1e3a"
|
||||
bg-selected: "#155DFF22"
|
||||
border-primary: "#2a2a4a"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#f44336"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF33"
|
||||
accent-green-light: "#00B38B33"
|
||||
accent-purple-light: "#A932FF33"
|
||||
accent-red-light: "#f4433633"
|
||||
accent-yellow-light: "#F0B10033"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
font-size-base: 14px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#155DFF"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# Dark
|
||||
@@ -777,19 +1033,147 @@ Dark variant with deep navy tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/minimal.md': `---
|
||||
type: Theme
|
||||
title: Minimal
|
||||
primary: "#000000"
|
||||
Description: High contrast, minimal chrome
|
||||
background: "#FAFAFA"
|
||||
foreground: "#111111"
|
||||
sidebar: "#F5F5F5"
|
||||
border: "#E0E0E0"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#000000"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#F0F0F0"
|
||||
secondary-foreground: "#111111"
|
||||
muted: "#F5F5F5"
|
||||
muted-foreground: "#888888"
|
||||
muted-foreground: "#666666"
|
||||
accent: "#F0F0F0"
|
||||
accent-foreground: "#000000"
|
||||
accent-foreground: "#111111"
|
||||
destructive: "#CC0000"
|
||||
border: "#E0E0E0"
|
||||
input: "#E0E0E0"
|
||||
ring: "#000000"
|
||||
sidebar: "#F5F5F5"
|
||||
sidebar-foreground: "#111111"
|
||||
sidebar-border: "#E0E0E0"
|
||||
sidebar-accent: "#E8E8E8"
|
||||
text-primary: "#111111"
|
||||
text-secondary: "#666666"
|
||||
text-tertiary: "#999999"
|
||||
text-muted: "#999999"
|
||||
text-heading: "#111111"
|
||||
bg-primary: "#FAFAFA"
|
||||
bg-card: "#FFFFFF"
|
||||
bg-sidebar: "#F5F5F5"
|
||||
bg-hover: "#EBEBEB"
|
||||
bg-hover-subtle: "#F5F5F5"
|
||||
bg-selected: "#00000014"
|
||||
border-primary: "#E0E0E0"
|
||||
accent-blue: "#000000"
|
||||
accent-green: "#006600"
|
||||
accent-orange: "#996600"
|
||||
accent-red: "#CC0000"
|
||||
accent-purple: "#660099"
|
||||
accent-yellow: "#996600"
|
||||
accent-blue-light: "#00000014"
|
||||
accent-green-light: "#00660014"
|
||||
accent-purple-light: "#66009914"
|
||||
accent-red-light: "#CC000014"
|
||||
accent-yellow-light: "#99660014"
|
||||
font-family: "'SF Mono', 'Menlo', monospace"
|
||||
font-size-base: 13
|
||||
line-height-base: 1.5
|
||||
font-size-base: 13px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.6
|
||||
editor-max-width: 680px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#000000"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# Minimal
|
||||
|
||||
@@ -826,6 +826,34 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
properties: {},
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/config.md',
|
||||
filename: 'config.md',
|
||||
title: 'Config',
|
||||
isA: 'Type',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 30,
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 300,
|
||||
snippet: 'Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.',
|
||||
wordCount: 25,
|
||||
relationships: {},
|
||||
icon: 'gear-six',
|
||||
color: 'gray',
|
||||
order: 90,
|
||||
sidebarLabel: 'Config',
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/recipe.md',
|
||||
filename: 'recipe.md',
|
||||
@@ -883,6 +911,36 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
properties: {},
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/config/agents.md',
|
||||
filename: 'agents.md',
|
||||
title: 'Agent Instructions',
|
||||
isA: 'Config',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 5,
|
||||
createdAt: now - 86400 * 30,
|
||||
fileSize: 1200,
|
||||
snippet: 'Vault instructions for AI agents. Defines how tools and integrations interact with this vault.',
|
||||
wordCount: 200,
|
||||
relationships: {
|
||||
'Type': ['[[type/config]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/recipe/pasta-carbonara.md',
|
||||
filename: 'pasta-carbonara.md',
|
||||
|
||||
@@ -114,7 +114,13 @@ const mockThemes: ThemeFile[] = [
|
||||
|
||||
let mockDeviceFlowPollCount = 0
|
||||
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string }) {
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
|
||||
if (oldTitle === args.new_title) {
|
||||
return { new_path: args.old_path, updated_files: 0 }
|
||||
}
|
||||
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
@@ -123,9 +129,6 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl
|
||||
|
||||
delete MOCK_CONTENT[args.old_path]
|
||||
MOCK_CONTENT[newPath] = newContent
|
||||
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
let updatedFiles = 0
|
||||
if (oldTitle) {
|
||||
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
@@ -227,7 +230,16 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
if (currentFolder === slug) return { new_path: args.note_path, updated_links: 0, moved: false }
|
||||
const filename = args.note_path.split('/').pop() ?? ''
|
||||
const vaultPath = args.vault_path.replace(/\/$/, '')
|
||||
const newPath = `${vaultPath}/${slug}/${filename}`
|
||||
// Handle collisions: append -2, -3, etc. if the target path already exists
|
||||
// (mirrors the Rust unique_dest_path logic).
|
||||
let newPath = `${vaultPath}/${slug}/${filename}`
|
||||
if (newPath in MOCK_CONTENT && newPath !== args.note_path) {
|
||||
const stem = filename.replace(/\.md$/, '')
|
||||
const ext = filename.endsWith('.md') ? '.md' : ''
|
||||
let counter = 2
|
||||
while (`${vaultPath}/${slug}/${stem}-${counter}${ext}` in MOCK_CONTENT) counter++
|
||||
newPath = `${vaultPath}/${slug}/${stem}-${counter}${ext}`
|
||||
}
|
||||
const content = MOCK_CONTENT[args.note_path] ?? ''
|
||||
delete MOCK_CONTENT[args.note_path]
|
||||
MOCK_CONTENT[newPath] = content
|
||||
@@ -249,6 +261,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
|
||||
purge_trash: () => [],
|
||||
delete_note: (args: { path: string }) => args.path,
|
||||
batch_delete_notes: (args: { paths: string[] }) => args.paths,
|
||||
empty_trash: () => [],
|
||||
migrate_is_a_to_type: () => 0,
|
||||
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
@@ -317,23 +331,153 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
|
||||
const path = `${args.vaultPath}/theme/${slug}.md`
|
||||
MOCK_CONTENT[path] = `---
|
||||
type: Theme
|
||||
title: ${displayName}
|
||||
primary: "#155DFF"
|
||||
Is A: Theme
|
||||
Description: ${displayName} theme
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
sidebar: "#F7F6F3"
|
||||
border: "#E9E9E7"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#EBEBEA"
|
||||
secondary-foreground: "#37352F"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#9B9A97"
|
||||
accent: "#F0F7FF"
|
||||
accent-foreground: "#0A3B8F"
|
||||
muted-foreground: "#787774"
|
||||
accent: "#EBEBEA"
|
||||
accent-foreground: "#37352F"
|
||||
destructive: "#E03E3E"
|
||||
border: "#E9E9E7"
|
||||
input: "#E9E9E7"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
sidebar-foreground: "#37352F"
|
||||
sidebar-border: "#E9E9E7"
|
||||
sidebar-accent: "#EBEBEA"
|
||||
text-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-tertiary: "#B4B4B4"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-card: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#E03E3E"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF14"
|
||||
accent-green-light: "#00B38B14"
|
||||
accent-purple-light: "#A932FF14"
|
||||
accent-red-light: "#E03E3E14"
|
||||
accent-yellow-light: "#F0B10014"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
font-size-base: 14px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#177bfd"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# ${displayName}
|
||||
|
||||
A custom ${displayName} theme for Laputa.
|
||||
`
|
||||
const now = Date.now() / 1000
|
||||
MOCK_ENTRIES.push({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Vault API detection and proxy for browser dev mode.
|
||||
* When a local vault API server is running, routes read commands through it
|
||||
* instead of returning hardcoded mock data.
|
||||
* When a local vault API server is running, routes read and write commands
|
||||
* through it instead of returning hardcoded mock data.
|
||||
*/
|
||||
|
||||
let vaultApiAvailable: boolean | null = null
|
||||
@@ -18,25 +18,60 @@ async function checkVaultApi(): Promise<boolean> {
|
||||
return vaultApiAvailable
|
||||
}
|
||||
|
||||
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => string | null> = {
|
||||
list_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
reload_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` : null,
|
||||
get_note_content: (args) => args.path ? `/api/vault/content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
get_all_content: (args) => args.path ? `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
interface VaultApiRequest {
|
||||
url: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
/** Tracks last vault path for commands that don't receive it as an argument. */
|
||||
let lastVaultPath: string | null = null
|
||||
|
||||
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => VaultApiRequest | null> = {
|
||||
list_vault: (args) => {
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}` } : null
|
||||
},
|
||||
reload_vault: (args) => {
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` } : null
|
||||
},
|
||||
reload_vault_entry: (args) =>
|
||||
args.path ? { url: `/api/vault/entry?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
get_note_content: (args) =>
|
||||
args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
get_all_content: (args) =>
|
||||
args.path ? { url: `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
save_note_content: (args) =>
|
||||
args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null,
|
||||
rename_note: (args) =>
|
||||
args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null,
|
||||
delete_note: (args) =>
|
||||
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
|
||||
search_vault: (args) => {
|
||||
const q = args.query as string
|
||||
if (!q || !lastVaultPath) return null
|
||||
return { url: `/api/vault/search?vault_path=${encodeURIComponent(lastVaultPath)}&query=${encodeURIComponent(q)}&mode=${encodeURIComponent((args.mode as string) || 'all')}` }
|
||||
},
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const available = await checkVaultApi()
|
||||
if (!available) return undefined
|
||||
|
||||
const urlBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!urlBuilder || !args) return undefined
|
||||
const requestBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!requestBuilder || !args) return undefined
|
||||
|
||||
const url = urlBuilder(args)
|
||||
if (!url) return undefined
|
||||
const request = requestBuilder(args)
|
||||
if (!request) return undefined
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const fetchOpts: RequestInit = { method: request.method || 'GET' }
|
||||
if (request.body) {
|
||||
fetchOpts.headers = { 'Content-Type': 'application/json' }
|
||||
fetchOpts.body = JSON.stringify(request.body)
|
||||
}
|
||||
const res = await fetch(request.url, fetchOpts)
|
||||
if (!res.ok) return undefined
|
||||
const data = await res.json()
|
||||
return (cmd === 'get_note_content' ? data.content : data) as T
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
import { wikilinkTarget } from './wikilink'
|
||||
import { wikilinkTarget, resolveEntry } from './wikilink'
|
||||
import { splitFrontmatter } from './wikilinks'
|
||||
|
||||
/** Extract only the body text from raw file content (strips YAML frontmatter). */
|
||||
@@ -13,16 +13,10 @@ function extractBody(rawContent: string): string {
|
||||
return body.trim()
|
||||
}
|
||||
|
||||
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. */
|
||||
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem.
|
||||
* Delegates to the unified resolveEntry for consistent matching. */
|
||||
export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined {
|
||||
const lower = target.toLowerCase()
|
||||
return entries.find(e => {
|
||||
if (e.title.toLowerCase() === lower) return true
|
||||
if (e.aliases.some(a => a.toLowerCase() === lower)) return true
|
||||
const stem = e.filename.replace(/\.md$/, '')
|
||||
if (stem.toLowerCase() === lower) return true
|
||||
return false
|
||||
})
|
||||
return resolveEntry(entries, target)
|
||||
}
|
||||
|
||||
/** Collect first-degree linked notes from the active entry. */
|
||||
|
||||
102
src/utils/compact-markdown.test.ts
Normal file
102
src/utils/compact-markdown.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { compactMarkdown } from './compact-markdown'
|
||||
|
||||
describe('compactMarkdown', () => {
|
||||
it('collapses blank lines between bullet list items (tight list)', () => {
|
||||
const input = '* Item one\n\n* Item two\n\n* Item three\n'
|
||||
expect(compactMarkdown(input)).toBe('* Item one\n* Item two\n* Item three\n')
|
||||
})
|
||||
|
||||
it('collapses blank lines between dash list items', () => {
|
||||
const input = '- Item one\n\n- Item two\n\n- Item three\n'
|
||||
expect(compactMarkdown(input)).toBe('- Item one\n- Item two\n- Item three\n')
|
||||
})
|
||||
|
||||
it('collapses blank lines between numbered list items', () => {
|
||||
const input = '1. First\n\n2. Second\n\n3. Third\n'
|
||||
expect(compactMarkdown(input)).toBe('1. First\n2. Second\n3. Third\n')
|
||||
})
|
||||
|
||||
it('removes extra blank line after heading', () => {
|
||||
const input = '## Personal\n\n* Back on track\n\n* Good health\n'
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track\n* Good health\n')
|
||||
})
|
||||
|
||||
it('preserves single blank line between heading and content', () => {
|
||||
const input = '## Title\n\nSome paragraph text.\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n')
|
||||
})
|
||||
|
||||
it('collapses multiple blank lines after heading to one', () => {
|
||||
const input = '## Title\n\n\n\nSome paragraph text.\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n')
|
||||
})
|
||||
|
||||
it('preserves blank lines between paragraphs', () => {
|
||||
const input = 'First paragraph.\n\nSecond paragraph.\n'
|
||||
expect(compactMarkdown(input)).toBe('First paragraph.\n\nSecond paragraph.\n')
|
||||
})
|
||||
|
||||
it('preserves blank lines inside fenced code blocks', () => {
|
||||
const input = '```\nline one\n\nline two\n\nline three\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```\nline one\n\nline two\n\nline three\n```\n')
|
||||
})
|
||||
|
||||
it('preserves blank lines inside fenced code blocks with language', () => {
|
||||
const input = '```js\nconst a = 1\n\nconst b = 2\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```js\nconst a = 1\n\nconst b = 2\n```\n')
|
||||
})
|
||||
|
||||
it('does not add trailing blank lines', () => {
|
||||
const input = '## Title\n\nText.\n\n\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n\nText.\n')
|
||||
})
|
||||
|
||||
it('handles the exact bug scenario from the issue', () => {
|
||||
const input = '## Personal\n\n* Back on track with Flavia\n\n* Good health vitals in place\n\n'
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track with Flavia\n* Good health vitals in place\n')
|
||||
})
|
||||
|
||||
it('handles heading followed immediately by list (no blank line)', () => {
|
||||
const input = '## Title\n* Item one\n\n* Item two\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n* Item one\n* Item two\n')
|
||||
})
|
||||
|
||||
it('handles mixed content: heading, paragraph, list', () => {
|
||||
const input = '# Title\n\nSome intro.\n\n* Item one\n\n* Item two\n\nConclusion.\n'
|
||||
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n* Item one\n* Item two\n\nConclusion.\n')
|
||||
})
|
||||
|
||||
it('preserves empty input', () => {
|
||||
expect(compactMarkdown('')).toBe('')
|
||||
})
|
||||
|
||||
it('preserves single line', () => {
|
||||
expect(compactMarkdown('Hello\n')).toBe('Hello\n')
|
||||
})
|
||||
|
||||
it('collapses 3+ consecutive blank lines to one blank line', () => {
|
||||
const input = 'Paragraph one.\n\n\n\nParagraph two.\n'
|
||||
expect(compactMarkdown(input)).toBe('Paragraph one.\n\nParagraph two.\n')
|
||||
})
|
||||
|
||||
it('handles list after code block', () => {
|
||||
const input = '```\ncode\n```\n\n* Item one\n\n* Item two\n'
|
||||
expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n* Item one\n* Item two\n')
|
||||
})
|
||||
|
||||
it('handles nested list items', () => {
|
||||
const input = '* Parent\n\n * Child one\n\n * Child two\n'
|
||||
expect(compactMarkdown(input)).toBe('* Parent\n * Child one\n * Child two\n')
|
||||
})
|
||||
|
||||
it('handles checklist items', () => {
|
||||
const input = '* [ ] Todo one\n\n* [ ] Todo two\n\n* [x] Done\n'
|
||||
expect(compactMarkdown(input)).toBe('* [ ] Todo one\n* [ ] Todo two\n* [x] Done\n')
|
||||
})
|
||||
|
||||
it('handles blockquotes normally', () => {
|
||||
const input = '> Quote line one\n\n> Quote line two\n'
|
||||
expect(compactMarkdown(input)).toBe('> Quote line one\n\n> Quote line two\n')
|
||||
})
|
||||
})
|
||||
82
src/utils/compact-markdown.ts
Normal file
82
src/utils/compact-markdown.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Post-process BlockNote's blocksToMarkdownLossy output to produce
|
||||
* standard-convention Markdown:
|
||||
* - Tight lists (no blank lines between consecutive list items)
|
||||
* - No runs of 3+ blank lines (collapsed to one blank line)
|
||||
* - No trailing blank lines
|
||||
* - Code block content is never modified
|
||||
*/
|
||||
export function compactMarkdown(md: string): string {
|
||||
if (!md) return md
|
||||
|
||||
const lines = md.split('\n')
|
||||
const result: string[] = []
|
||||
let inCodeBlock = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Track fenced code blocks — never modify content inside them
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip blank lines that sit between two list items (tight list rule)
|
||||
if (line.trim() === '') {
|
||||
if (isBlankBetweenListItems(lines, i)) continue
|
||||
// Collapse runs of 3+ blank lines to a single blank line
|
||||
if (isExcessiveBlankLine(lines, i)) continue
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
// Trim trailing blank lines, keep exactly one trailing newline
|
||||
while (result.length > 0 && result[result.length - 1].trim() === '') {
|
||||
result.pop()
|
||||
}
|
||||
if (result.length > 0) result.push('')
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
const LIST_RE = /^(\s*)([-*+]|\d+\.)\s/
|
||||
|
||||
/** True if this blank line sits between two list items (including nested) */
|
||||
function isBlankBetweenListItems(lines: string[], idx: number): boolean {
|
||||
const prev = findPrevNonBlank(lines, idx)
|
||||
const next = findNextNonBlank(lines, idx)
|
||||
if (prev === null || next === null) return false
|
||||
return LIST_RE.test(lines[prev]) && LIST_RE.test(lines[next])
|
||||
}
|
||||
|
||||
/** True if this blank line is part of a run of 2+ consecutive blank lines
|
||||
* (i.e. would create 3+ newlines in a row — collapse to just one blank line) */
|
||||
function isExcessiveBlankLine(lines: string[], idx: number): boolean {
|
||||
// Keep the first blank line in a run, skip subsequent ones
|
||||
if (idx > 0 && lines[idx - 1].trim() === '') return true
|
||||
return false
|
||||
}
|
||||
|
||||
function findPrevNonBlank(lines: string[], idx: number): number | null {
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (lines[i].trim() !== '') return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findNextNonBlank(lines: string[], idx: number): number | null {
|
||||
for (let i = idx + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() !== '') return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
46
src/utils/frontmatter.test.ts
Normal file
46
src/utils/frontmatter.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseFrontmatter } from './frontmatter'
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
describe('boolean-like Yes/No values', () => {
|
||||
it('parses Archived: Yes as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses Archived: No as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: No\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
|
||||
it('parses Trashed: Yes as true', () => {
|
||||
const fm = parseFrontmatter('---\nTrashed: Yes\n---\nBody')
|
||||
expect(fm['Trashed']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses Trashed: No as false', () => {
|
||||
const fm = parseFrontmatter('---\nTrashed: No\n---\nBody')
|
||||
expect(fm['Trashed']).toBe(false)
|
||||
})
|
||||
|
||||
it('parses yes (lowercase) as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: yes\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses no (lowercase) as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: no\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
|
||||
it('still parses true as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: true\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('still parses false as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: false\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,8 +23,9 @@ function parseInlineArray(value: string): FrontmatterValue {
|
||||
|
||||
function parseScalar(value: string): FrontmatterValue {
|
||||
const clean = unquote(value)
|
||||
if (clean.toLowerCase() === 'true') return true
|
||||
if (clean.toLowerCase() === 'false') return false
|
||||
const lower = clean.toLowerCase()
|
||||
if (lower === 'true' || lower === 'yes') return true
|
||||
if (lower === 'false' || lower === 'no') return false
|
||||
return clean
|
||||
}
|
||||
|
||||
|
||||
27
src/utils/iconRegistry.test.ts
Normal file
27
src/utils/iconRegistry.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveIcon, ICON_OPTIONS } from './iconRegistry'
|
||||
import { FileText, GearSix, CookingPot } from '@phosphor-icons/react'
|
||||
|
||||
describe('resolveIcon', () => {
|
||||
it('returns FileText for null', () => {
|
||||
expect(resolveIcon(null)).toBe(FileText)
|
||||
})
|
||||
|
||||
it('returns FileText for unknown icon name', () => {
|
||||
expect(resolveIcon('nonexistent-icon')).toBe(FileText)
|
||||
})
|
||||
|
||||
it('resolves gear-six to GearSix', () => {
|
||||
expect(resolveIcon('gear-six')).toBe(GearSix)
|
||||
})
|
||||
|
||||
it('resolves cooking-pot to CookingPot', () => {
|
||||
expect(resolveIcon('cooking-pot')).toBe(CookingPot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ICON_OPTIONS', () => {
|
||||
it('includes gear-six', () => {
|
||||
expect(ICON_OPTIONS.some((o) => o.name === 'gear-six')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Door, Drop, Envelope, Eye, Eyeglasses, Factory, Fan, Farm, Feather, File,
|
||||
FileCode, FileText, FilmReel, Fingerprint, Fire, FirstAid, Fish, Flag, Flame, Flashlight,
|
||||
Flask, Flower, FlowerLotus, Folder, FolderOpen, Football, ForkKnife, Function, Funnel, GameController,
|
||||
Gauge, Gavel, Gear, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
|
||||
Gauge, Gavel, Gear, GearSix, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
|
||||
Hand, Handshake, Headphones, Headset, Heart, Heartbeat, Horse, Hospital, Hourglass, House,
|
||||
IceCream, IdentificationBadge, Image, Infinity as InfinityIcon, Island, Joystick, Kanban, Key, Keyboard, Knife,
|
||||
Ladder, Lamp, Laptop, Leaf, Lifebuoy, Lightbulb, Lighthouse, Lightning, Link, List,
|
||||
@@ -174,6 +174,7 @@ export const ICON_OPTIONS: IconEntry[] = [
|
||||
{ name: 'gauge', Icon: Gauge },
|
||||
{ name: 'gavel', Icon: Gavel },
|
||||
{ name: 'gear', Icon: Gear },
|
||||
{ name: 'gear-six', Icon: GearSix },
|
||||
{ name: 'ghost', Icon: Ghost },
|
||||
{ name: 'gift', Icon: Gift },
|
||||
{ name: 'git-branch', Icon: GitBranch },
|
||||
|
||||
65
src/utils/sidebarSections.ts
Normal file
65
src/utils/sidebarSections.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure functions for building sidebar section groups from vault entries.
|
||||
* Extracted from Sidebar.tsx for testability.
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { SectionGroup } from '../components/SidebarParts'
|
||||
import { resolveIcon } from './iconRegistry'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, StackSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Projects', type: 'Project', Icon: Wrench },
|
||||
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
|
||||
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
|
||||
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
|
||||
{ label: 'People', type: 'Person', Icon: Users },
|
||||
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
|
||||
{ label: 'Topics', type: 'Topic', Icon: Tag },
|
||||
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
||||
]
|
||||
|
||||
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
|
||||
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
|
||||
|
||||
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
|
||||
export function collectActiveTypes(entries: VaultEntry[]): Set<string> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
|
||||
export function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
|
||||
const builtIn = BUILT_IN_TYPE_MAP.get(type)
|
||||
const typeEntry = typeEntryMap[type]
|
||||
const customColor = typeEntry?.color ?? null
|
||||
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
|
||||
if (builtIn) {
|
||||
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
|
||||
return { ...builtIn, label, Icon, customColor }
|
||||
}
|
||||
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
}
|
||||
|
||||
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
|
||||
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
export function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const orderA = typeEntryMap[a.type]?.order ?? Infinity
|
||||
const orderB = typeEntryMap[b.type]?.order ?? Infinity
|
||||
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
export { BUILT_IN_SECTION_GROUPS }
|
||||
@@ -42,6 +42,34 @@ describe('attachClickHandlers', () => {
|
||||
)
|
||||
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' })
|
||||
})
|
||||
|
||||
it('uses path|title target when candidates have duplicate titles', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/project/status-update.md' },
|
||||
{ title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/journal/status-update.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink)
|
||||
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('project/status-update|Status Update')
|
||||
result[1].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('journal/status-update|Status Update')
|
||||
})
|
||||
|
||||
it('uses title-only target when titles are unique', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/note/alpha.md' },
|
||||
{ title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/note/beta.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink)
|
||||
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('Alpha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enrichSuggestionItems', () => {
|
||||
|
||||
@@ -16,14 +16,31 @@ interface BaseSuggestionItem {
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Add onItemClick to raw suggestion candidates */
|
||||
/** Build a vault-relative path target with pipe display: "type/slug|Title" */
|
||||
function buildPathTarget(item: BaseSuggestionItem): string {
|
||||
const parts = item.path.split('/')
|
||||
const vaultRelPath = parts.slice(-2).join('/').replace(/\.md$/, '')
|
||||
return `${vaultRelPath}|${item.entryTitle}`
|
||||
}
|
||||
|
||||
/** Add onItemClick to raw suggestion candidates.
|
||||
* When multiple candidates share the same title, inserts a path-based
|
||||
* target with pipe syntax so the wikilink uniquely identifies the note. */
|
||||
export function attachClickHandlers(
|
||||
candidates: BaseSuggestionItem[],
|
||||
insertWikilink: (target: string) => void,
|
||||
) {
|
||||
const titleCounts = new Map<string, number>()
|
||||
for (const item of candidates) {
|
||||
titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return candidates.map(item => ({
|
||||
...item,
|
||||
onItemClick: () => insertWikilink(item.entryTitle),
|
||||
onItemClick: () => {
|
||||
const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1
|
||||
insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { buildThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from './themeSchema'
|
||||
import type { ThemeProperty } from './themeSchema'
|
||||
|
||||
@@ -124,6 +126,41 @@ describe('buildThemeSchema', () => {
|
||||
expect(fontStyle.options).toContain('normal')
|
||||
expect(fontStyle.options).toContain('italic')
|
||||
})
|
||||
|
||||
it('every var(--xxx) in EditorTheme.css has a matching default in theme schema or base UI', () => {
|
||||
const cssPath = resolve(__dirname, '../components/EditorTheme.css')
|
||||
const css = readFileSync(cssPath, 'utf-8')
|
||||
|
||||
// Extract all var(--xxx) references (first arg only, ignore fallbacks)
|
||||
const varRegex = /var\(--([a-z0-9-]+)/g
|
||||
const usedVars = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = varRegex.exec(css)) !== null) {
|
||||
usedVars.add(match[1])
|
||||
}
|
||||
|
||||
// Collect all CSS var names from the schema
|
||||
const schemaVars = new Set<string>()
|
||||
for (const section of schema) {
|
||||
for (const prop of section.properties) schemaVars.add(prop.cssVar)
|
||||
for (const sub of section.subsections) {
|
||||
for (const prop of sub.properties) schemaVars.add(prop.cssVar)
|
||||
}
|
||||
}
|
||||
|
||||
// Base UI color vars set by the theme color system (not in theme.json schema)
|
||||
const baseUIVars = new Set([
|
||||
'border-primary', 'bg-primary', 'bg-card', 'bg-hover-subtle', 'bg-selected',
|
||||
'text-primary', 'text-secondary', 'text-muted', 'text-heading', 'text-tertiary',
|
||||
'accent-blue',
|
||||
])
|
||||
|
||||
for (const varName of usedVars) {
|
||||
expect(
|
||||
schemaVars.has(varName) || baseUIVars.has(varName),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForFrontmatter', () => {
|
||||
|
||||
@@ -28,6 +28,10 @@ describe('getTypeColor', () => {
|
||||
it('ignores invalid custom color key', () => {
|
||||
expect(getTypeColor('Project', 'invalid')).toBe('var(--accent-red)')
|
||||
})
|
||||
|
||||
it('uses gray custom color key', () => {
|
||||
expect(getTypeColor('Config', 'gray')).toBe('var(--accent-gray)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeLightColor', () => {
|
||||
@@ -47,6 +51,10 @@ describe('getTypeLightColor', () => {
|
||||
it('uses custom color key for light variant', () => {
|
||||
expect(getTypeLightColor('Recipe', 'purple')).toBe('var(--accent-purple-light)')
|
||||
})
|
||||
|
||||
it('uses gray custom color key for light variant', () => {
|
||||
expect(getTypeLightColor('Config', 'gray')).toBe('var(--accent-gray-light)')
|
||||
})
|
||||
})
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
@@ -54,20 +62,22 @@ const baseEntry: VaultEntry = {
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
|
||||
describe('buildTypeEntryMap', () => {
|
||||
it('indexes Type entries by title', () => {
|
||||
it('indexes Type entries by title and lowercase', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' },
|
||||
{ ...baseEntry, title: 'My Note', isA: 'Note' },
|
||||
{ ...baseEntry, title: 'Evergreen', isA: 'Type', color: 'green', icon: 'leaf' },
|
||||
]
|
||||
const map = buildTypeEntryMap(entries)
|
||||
expect(Object.keys(map)).toEqual(['Recipe', 'Evergreen'])
|
||||
expect(map['Recipe'].color).toBe('orange')
|
||||
expect(map['recipe'].color).toBe('orange')
|
||||
expect(map['Evergreen'].icon).toBe('leaf')
|
||||
expect(map['evergreen'].icon).toBe('leaf')
|
||||
})
|
||||
|
||||
it('returns empty map when no Type entries exist', () => {
|
||||
@@ -76,4 +86,15 @@ describe('buildTypeEntryMap', () => {
|
||||
]
|
||||
expect(buildTypeEntryMap(entries)).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves sidebarLabel in type entry via exact and lowercase keys', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
]
|
||||
const map = buildTypeEntryMap(entries)
|
||||
expect(map['Config'].sidebarLabel).toBe('Config')
|
||||
expect(map['config'].sidebarLabel).toBe('Config')
|
||||
expect(map['config'].icon).toBe('gear-six')
|
||||
expect(map['config'].color).toBe('gray')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
/** Builds a map from type name → Type document entry (for custom color/icon lookup) */
|
||||
/** Builds a map from type name → Type document entry (for custom color/icon lookup).
|
||||
* Stores both original title and lowercase version so lookups work regardless
|
||||
* of whether instances use `isA: Config` or `isA: config`. */
|
||||
export function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
|
||||
for (const e of entries) {
|
||||
if (e.isA === 'Type') {
|
||||
map[e.title] = e
|
||||
const lower = e.title.toLowerCase()
|
||||
if (lower !== e.title) map[lower] = e
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -47,6 +55,7 @@ export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight:
|
||||
{ key: 'purple', label: 'Purple', css: 'var(--accent-purple)', cssLight: 'var(--accent-purple-light)' },
|
||||
{ key: 'teal', label: 'Teal', css: 'var(--accent-teal)', cssLight: 'var(--accent-teal-light)' },
|
||||
{ key: 'pink', label: 'Pink', css: 'var(--accent-pink)', cssLight: 'var(--accent-pink-light)' },
|
||||
{ key: 'gray', label: 'Gray', css: 'var(--accent-gray)', cssLight: 'var(--accent-gray-light)' },
|
||||
]
|
||||
|
||||
const COLOR_KEY_TO_CSS: Record<string, string> = Object.fromEntries(
|
||||
|
||||
88
src/utils/wikilink.test.ts
Normal file
88
src/utils/wikilink.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { wikilinkTarget, wikilinkDisplay, resolveEntry } from './wikilink'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note.md', filename: 'note.md', title: 'Note', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, template: null,
|
||||
sort: null, outgoingLinks: [], sidebarLabel: null, view: null, visible: null,
|
||||
properties: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('wikilinkTarget', () => {
|
||||
it('strips brackets', () => {
|
||||
expect(wikilinkTarget('[[foo]]')).toBe('foo')
|
||||
})
|
||||
it('returns target before pipe', () => {
|
||||
expect(wikilinkTarget('[[path|display]]')).toBe('path')
|
||||
})
|
||||
it('handles bare text without brackets', () => {
|
||||
expect(wikilinkTarget('just text')).toBe('just text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('wikilinkDisplay', () => {
|
||||
it('returns text after pipe', () => {
|
||||
expect(wikilinkDisplay('[[path|My Title]]')).toBe('My Title')
|
||||
})
|
||||
it('humanises slug when no pipe', () => {
|
||||
expect(wikilinkDisplay('[[my-note]]')).toBe('My Note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEntry', () => {
|
||||
const alice = makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice', isA: 'Person', aliases: ['Alice Smith'] })
|
||||
const bob = makeEntry({ path: '/vault/person/bob.md', filename: 'bob.md', title: 'Bob', isA: 'Person' })
|
||||
const project = makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', isA: 'Project' })
|
||||
const entries = [alice, bob, project]
|
||||
|
||||
it('matches by title (case-insensitive)', () => {
|
||||
expect(resolveEntry(entries, 'alice')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'ALICE')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'Alice')).toBe(alice)
|
||||
})
|
||||
|
||||
it('matches by alias (case-insensitive)', () => {
|
||||
expect(resolveEntry(entries, 'alice smith')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'Alice Smith')).toBe(alice)
|
||||
})
|
||||
|
||||
it('matches by filename stem (case-insensitive)', () => {
|
||||
expect(resolveEntry(entries, 'my-project')).toBe(project)
|
||||
expect(resolveEntry(entries, 'My-Project')).toBe(project)
|
||||
})
|
||||
|
||||
it('matches by relative path suffix (type/slug)', () => {
|
||||
expect(resolveEntry(entries, 'person/alice')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'project/my-project')).toBe(project)
|
||||
})
|
||||
|
||||
it('handles pipe syntax: uses target part for lookup', () => {
|
||||
expect(resolveEntry(entries, 'person/alice|Alice S.')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'Alice|A')).toBe(alice)
|
||||
})
|
||||
|
||||
it('returns undefined for non-existent target', () => {
|
||||
expect(resolveEntry(entries, 'Does Not Exist')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty entries', () => {
|
||||
expect(resolveEntry([], 'Alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('matches by filename stem from last segment of path target', () => {
|
||||
// If target is "person/alice", the last segment "alice" should match filename stem
|
||||
expect(resolveEntry(entries, 'person/alice')).toBe(alice)
|
||||
})
|
||||
|
||||
it('matches title-as-words from kebab-case target', () => {
|
||||
// "my-project" → "my project" should match title "My Project"
|
||||
expect(resolveEntry(entries, 'my-project')).toBe(project)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Utility functions for parsing wikilink syntax: [[target|display]] */
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
/** Extracts the target path from a wikilink reference (strips [[ ]] and display text). */
|
||||
export function wikilinkTarget(ref: string): string {
|
||||
const inner = ref.replace(/^\[\[|\]\]$/g, '')
|
||||
@@ -15,3 +17,26 @@ export function wikilinkDisplay(ref: string): string {
|
||||
const last = inner.split('/').pop() ?? inner
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
|
||||
* Handles pipe syntax, case-insensitive matching, title/alias/filename/path lookup.
|
||||
*/
|
||||
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
|
||||
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
|
||||
const keyLower = key.toLowerCase()
|
||||
const suffix = '/' + key + '.md'
|
||||
const lastSegment = key.split('/').pop() ?? key
|
||||
const asWords = lastSegment.replace(/-/g, ' ').toLowerCase()
|
||||
|
||||
return entries.find(e => {
|
||||
if (e.title.toLowerCase() === keyLower) return true
|
||||
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return true
|
||||
const stem = e.filename.replace(/\.md$/, '')
|
||||
if (stem.toLowerCase() === keyLower) return true
|
||||
if (e.path.endsWith(suffix)) return true
|
||||
if (stem.toLowerCase() === lastSegment.toLowerCase()) return true
|
||||
if (e.title.toLowerCase() === asWords) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
*/
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor } from './typeColors'
|
||||
import { resolveEntry } from './wikilink'
|
||||
|
||||
/** Broken-link color: muted text to signal the target note doesn't exist */
|
||||
const BROKEN_LINK_COLOR = 'var(--text-muted)'
|
||||
|
||||
/** Find a vault entry matching a wikilink target string */
|
||||
/** Find a vault entry matching a wikilink target string.
|
||||
* Delegates to the unified resolveEntry for consistent case-insensitive matching. */
|
||||
export function findEntryByTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
|
||||
// Handle pipe syntax: [[path|display name]] → use path part for matching
|
||||
const key = target.includes('|') ? target.split('|')[0] : target
|
||||
const suffix = '/' + key + '.md'
|
||||
return entries.find(e =>
|
||||
e.title === key ||
|
||||
e.filename.replace(/\.md$/, '') === key ||
|
||||
e.aliases.includes(key) ||
|
||||
e.path.endsWith(suffix),
|
||||
)
|
||||
return resolveEntry(entries, target)
|
||||
}
|
||||
|
||||
/** Resolve the accent color for a given entry based on its type */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext } from './wikilinks'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext, extractSnippet } from './wikilinks'
|
||||
|
||||
interface TestBlock {
|
||||
type?: string
|
||||
@@ -485,3 +485,97 @@ describe('extractBacklinkContext', () => {
|
||||
expect(result).toBe('Short [[My Note]].')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSnippet', () => {
|
||||
it('extracts first paragraph after frontmatter and title', () => {
|
||||
const content = '---\ntype: Note\n---\n\n# My Note\n\nThis is the first paragraph of content.\n\n## Section Two\n\nMore content here.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('This is the first paragraph')
|
||||
expect(snippet).toContain('More content here')
|
||||
})
|
||||
|
||||
it('strips markdown formatting (bold, italic, code)', () => {
|
||||
const content = '# Title\n\nSome **bold** and *italic* and `code` text.'
|
||||
expect(extractSnippet(content)).toBe('Some bold and italic and code text.')
|
||||
})
|
||||
|
||||
it('strips markdown links, keeps display text', () => {
|
||||
const content = '# Title\n\nSee [this link](https://example.com) and [[wiki link]].'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('this link')
|
||||
expect(snippet).not.toContain('https://example.com')
|
||||
expect(snippet).toContain('wiki link')
|
||||
expect(snippet).not.toContain('[[')
|
||||
})
|
||||
|
||||
it('uses display text from aliased wikilinks', () => {
|
||||
const content = '# Title\n\nDiscussed in [[meetings/standup|standup]] today.'
|
||||
expect(extractSnippet(content)).toBe('Discussed in standup today.')
|
||||
})
|
||||
|
||||
it('truncates long content to ~160 chars with ellipsis', () => {
|
||||
const content = `# Title\n\n${'word '.repeat(100)}`
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet.length).toBeLessThanOrEqual(165)
|
||||
expect(snippet).toMatch(/\.\.\.$/)
|
||||
})
|
||||
|
||||
it('returns empty string for note with only title', () => {
|
||||
const content = '---\ntype: Note\n---\n\n# Just a Title\n'
|
||||
expect(extractSnippet(content)).toBe('')
|
||||
})
|
||||
|
||||
it('skips code fence delimiters', () => {
|
||||
const content = '# Title\n\n```rust\nfn main() {}\n```\n\nReal content here.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).not.toContain('```')
|
||||
expect(snippet).toContain('Real content here')
|
||||
})
|
||||
|
||||
it('returns empty for content with only headings', () => {
|
||||
const content = '# Title\n\n## Section One\n\n### Sub Section\n'
|
||||
expect(extractSnippet(content)).toBe('')
|
||||
})
|
||||
|
||||
it('handles content without frontmatter or title', () => {
|
||||
const content = 'Just plain text content without any heading.'
|
||||
expect(extractSnippet(content)).toBe('Just plain text content without any heading.')
|
||||
})
|
||||
|
||||
it('skips horizontal rules', () => {
|
||||
const content = '# Title\n\n---\n\nContent after rule.'
|
||||
expect(extractSnippet(content)).toBe('Content after rule.')
|
||||
})
|
||||
|
||||
it('handles strikethrough', () => {
|
||||
const content = '# Title\n\n~~deleted~~ text remains.'
|
||||
expect(extractSnippet(content)).toBe('deleted text remains.')
|
||||
})
|
||||
|
||||
it('extracts snippet from project-template note with body text', () => {
|
||||
const content = [
|
||||
'---', 'type: Project', 'status: Active', '---', '',
|
||||
'# Ship MVP of Tolaria', '',
|
||||
'## Objective', '',
|
||||
'Ship the minimum viable product for Tolaria marketplace.', '',
|
||||
'## Key Results', '',
|
||||
'- 100 beta users signed up',
|
||||
].join('\n')
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('Ship the minimum viable product')
|
||||
})
|
||||
|
||||
it('includes list items in snippet', () => {
|
||||
const content = '# Title\n\n- First item\n- Second item\n- Third item'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('First item')
|
||||
expect(snippet).toContain('Second item')
|
||||
})
|
||||
|
||||
it('includes code content lines (not fences) in snippet', () => {
|
||||
const content = '# Title\n\n```\nfn main() {}\n```\n\nSome text.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('fn main()')
|
||||
expect(snippet).toContain('Some text.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,6 +154,62 @@ export function extractBacklinkContext(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule). */
|
||||
function isSnippetLine(line: string): boolean {
|
||||
const t = line.trim()
|
||||
return t !== '' && !t.startsWith('#') && !t.startsWith('```') && !t.startsWith('---')
|
||||
}
|
||||
|
||||
/** Remove the first H1 heading line, allowing leading blank lines. */
|
||||
function removeH1Line(body: string): string {
|
||||
const lines = body.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith('# ')) return lines.slice(i + 1).join('\n')
|
||||
if (lines[i].trim() !== '') return body
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** Strip markdown formatting chars: bold, italic, code, strikethrough, and resolve links. */
|
||||
function stripMarkdownChars(s: string): string {
|
||||
let result = ''
|
||||
let i = 0
|
||||
while (i < s.length) {
|
||||
if (s[i] === '[' && s[i + 1] === '[') {
|
||||
i += 2
|
||||
let inner = ''
|
||||
while (i < s.length - 1 && !(s[i] === ']' && s[i + 1] === ']')) { inner += s[i]; i++ }
|
||||
if (i < s.length - 1) i += 2
|
||||
const pipe = inner.indexOf('|')
|
||||
result += pipe !== -1 ? inner.slice(pipe + 1) : inner
|
||||
} else if (s[i] === '[') {
|
||||
i++
|
||||
let text = ''
|
||||
while (i < s.length && s[i] !== ']') { text += s[i]; i++ }
|
||||
if (i < s.length) i++
|
||||
if (i < s.length && s[i] === '(') { i++; while (i < s.length && s[i] !== ')') i++; if (i < s.length) i++ }
|
||||
result += text
|
||||
} else if (s[i] === '*' || s[i] === '_' || s[i] === '`' || s[i] === '~') {
|
||||
i++
|
||||
} else {
|
||||
result += s[i]
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Extract a snippet: first ~160 chars of body content, stripped of markdown.
|
||||
* Mirrors the Rust extract_snippet() logic for frontend use. */
|
||||
export function extractSnippet(content: string): string {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
const withoutH1 = removeH1Line(body)
|
||||
const clean = withoutH1.split('\n').filter(isSnippetLine).join(' ')
|
||||
const stripped = stripMarkdownChars(clean)
|
||||
if (stripped.length <= 160) return stripped
|
||||
return stripped.slice(0, 160) + '...'
|
||||
}
|
||||
|
||||
export function countWords(content: string): number {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
|
||||
|
||||
12
tests/fixtures/test-vault/event/team-meeting.md
vendored
Normal file
12
tests/fixtures/test-vault/event/team-meeting.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
Is A: Event
|
||||
Status: Active
|
||||
Belongs to:
|
||||
- "[[Alpha Project]]"
|
||||
Attendees:
|
||||
- "[[Test User]]"
|
||||
---
|
||||
|
||||
# Team Meeting
|
||||
|
||||
Weekly team meeting for the Alpha Project.
|
||||
8
tests/fixtures/test-vault/note/archived-note.md
vendored
Normal file
8
tests/fixtures/test-vault/note/archived-note.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
Is A: Note
|
||||
Archived: Yes
|
||||
---
|
||||
|
||||
# Archived Note
|
||||
|
||||
This note is archived and should not appear in the main sidebar.
|
||||
10
tests/fixtures/test-vault/note/note-b.md
vendored
Normal file
10
tests/fixtures/test-vault/note/note-b.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
---
|
||||
|
||||
# Note B
|
||||
|
||||
This is Note B, referenced by Alpha Project.
|
||||
|
||||
It also links to [[Note C]].
|
||||
12
tests/fixtures/test-vault/note/note-c.md
vendored
Normal file
12
tests/fixtures/test-vault/note/note-c.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
Related to:
|
||||
- "[[Alpha Project]]"
|
||||
Topics:
|
||||
- "[[design]]"
|
||||
---
|
||||
|
||||
# Note C
|
||||
|
||||
This is Note C with relationships to Alpha Project.
|
||||
9
tests/fixtures/test-vault/note/trashed-note.md
vendored
Normal file
9
tests/fixtures/test-vault/note/trashed-note.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Note
|
||||
Trashed: true
|
||||
Trashed at: 2026-03-01
|
||||
---
|
||||
|
||||
# Trashed Note
|
||||
|
||||
This note is trashed and should not appear in search results or the main sidebar.
|
||||
16
tests/fixtures/test-vault/project/alpha-project.md
vendored
Normal file
16
tests/fixtures/test-vault/project/alpha-project.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: "Test User"
|
||||
Related to:
|
||||
- "[[Note B]]"
|
||||
- "[[Note C]]"
|
||||
---
|
||||
|
||||
# Alpha Project
|
||||
|
||||
This is a test project that references other notes.
|
||||
|
||||
## Notes
|
||||
|
||||
See [[Note B]] for details and [[Note C]] for additional context.
|
||||
9
tests/fixtures/test-vault/type/event.md
vendored
Normal file
9
tests/fixtures/test-vault/type/event.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Type
|
||||
icon: calendar
|
||||
color: orange
|
||||
order: 2
|
||||
sidebarLabel: Events
|
||||
---
|
||||
|
||||
# Event
|
||||
9
tests/fixtures/test-vault/type/note.md
vendored
Normal file
9
tests/fixtures/test-vault/type/note.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Type
|
||||
icon: file-text
|
||||
color: green
|
||||
order: 1
|
||||
sidebarLabel: Notes
|
||||
---
|
||||
|
||||
# Note
|
||||
9
tests/fixtures/test-vault/type/project.md
vendored
Normal file
9
tests/fixtures/test-vault/type/project.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Type
|
||||
icon: folder
|
||||
color: blue
|
||||
order: 0
|
||||
sidebarLabel: Projects
|
||||
---
|
||||
|
||||
# Project
|
||||
230
tests/integration/vault-workflows.spec.ts
Normal file
230
tests/integration/vault-workflows.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Integration tests against a real filesystem vault.
|
||||
*
|
||||
* Each test copies tests/fixtures/test-vault/ to a temp directory,
|
||||
* points the app at it, and verifies real file I/O: creation, rename,
|
||||
* wikilink updates, archive/trash filtering, and relationship display.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
|
||||
|
||||
function copyDirSync(src: string, dest: string): void {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
for (const item of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const s = path.join(src, item.name)
|
||||
const d = path.join(dest, item.name)
|
||||
if (item.isDirectory()) copyDirSync(s, d)
|
||||
else fs.copyFileSync(s, d)
|
||||
}
|
||||
}
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Fresh vault copy for each test
|
||||
tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-'))
|
||||
copyDirSync(FIXTURE_VAULT, tempVaultDir)
|
||||
|
||||
// Intercept window.__mockHandlers assignment to override vault path handlers.
|
||||
// The Object.defineProperty setter fires when mock-tauri/index.ts sets
|
||||
// window.__mockHandlers = mockHandlers, letting us patch the same object
|
||||
// reference that mockInvoke reads from.
|
||||
await page.addInitScript((vaultPath: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ref: any = null
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
set(val) {
|
||||
ref = val
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [{ label: 'Test Vault', path: vaultPath }],
|
||||
active_vault: vaultPath,
|
||||
})
|
||||
ref.check_vault_exists = () => true
|
||||
ref.get_last_vault_path = () => vaultPath
|
||||
ref.get_default_vault_path = () => vaultPath
|
||||
ref.save_vault_list = () => null
|
||||
},
|
||||
get() { return ref },
|
||||
configurable: true,
|
||||
})
|
||||
}, tempVaultDir)
|
||||
|
||||
await page.goto('/')
|
||||
// Wait until the real vault entries are loaded in the sidebar
|
||||
await page.getByText('Alpha Project', { exact: true }).first().waitFor({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
fs.rmSync(tempVaultDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Helper: locate the note-list container. */
|
||||
function noteList(page: import('@playwright/test').Page) {
|
||||
return page.locator('[data-testid="note-list-container"]')
|
||||
}
|
||||
|
||||
/** Helper: click a note item by title text in the sidebar. */
|
||||
async function openNote(page: import('@playwright/test').Page, title: string) {
|
||||
await noteList(page).getByText(title, { exact: true }).click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Vault loads entries from real fixture files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('vault loads entries from fixture files', async ({ page }) => {
|
||||
const list = noteList(page)
|
||||
await expect(list.getByText('Alpha Project', { exact: true })).toBeVisible()
|
||||
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
|
||||
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
|
||||
await expect(list.getByText('Team Meeting', { exact: true })).toBeVisible()
|
||||
|
||||
// Open a note and verify editor shows its content from disk
|
||||
await openNote(page, 'Alpha Project')
|
||||
// The WYSIWYG editor renders the title as an H1 heading
|
||||
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Archived note hidden from main sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('archived note does not appear in All Notes', async ({ page }) => {
|
||||
const list = noteList(page)
|
||||
// "Archived Note" has Archived: Yes — should be hidden from default view
|
||||
await expect(list.getByText('Archived Note', { exact: true })).not.toBeVisible()
|
||||
|
||||
// But regular notes are visible
|
||||
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Trashed note hidden from note list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('trashed note does not appear in All Notes', async ({ page }) => {
|
||||
const list = noteList(page)
|
||||
// "Trashed Note" has Trashed: true — should be hidden
|
||||
await expect(list.getByText('Trashed Note', { exact: true })).not.toBeVisible()
|
||||
|
||||
// Regular notes are visible
|
||||
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Create note saves file to disk with correct slug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('create note saves file to disk with correct slug', async ({ page }) => {
|
||||
// "Create new note" instantly creates "Untitled note" and opens in editor
|
||||
await page.locator('button[title="Create new note"]').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify the new note opens — H1 heading should appear in the WYSIWYG editor
|
||||
await expect(page.getByRole('heading', { name: /Untitled note/i, level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
// Save to disk via Cmd+S
|
||||
await page.keyboard.press('Meta+s')
|
||||
|
||||
// Poll for the file to appear on disk
|
||||
const expectedPath = path.join(tempVaultDir, 'note', 'untitled-note.md')
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(expectedPath)).toBe(true)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
const content = fs.readFileSync(expectedPath, 'utf-8')
|
||||
expect(content).toContain('# Untitled note')
|
||||
expect(content).toContain('type: Note')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Rename note updates filename on disk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('rename note updates filename on disk', async ({ page }) => {
|
||||
// Open Note B
|
||||
await openNote(page, 'Note B')
|
||||
|
||||
// Double-click the tab title — scope to the draggable tab element to avoid
|
||||
// matching the breadcrumb span that also has class "truncate".
|
||||
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// In rename mode, draggable becomes false and an <input> appears in the tab.
|
||||
// It's the only <input> element on the page at this point.
|
||||
const input = page.locator('input').first()
|
||||
await expect(input).toBeVisible({ timeout: 2_000 })
|
||||
await input.fill('Note B Renamed')
|
||||
await input.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify filesystem: old file gone, new file exists
|
||||
const oldPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
const newPath = path.join(tempVaultDir, 'note', 'note-b-renamed.md')
|
||||
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(oldPath)).toBe(false)
|
||||
expect(fs.existsSync(newPath)).toBe(true)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
const newContent = fs.readFileSync(newPath, 'utf-8')
|
||||
expect(newContent).toContain('# Note B Renamed')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Wikilink update on rename — other files' [[Note B]] updated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('rename note updates wikilinks in other files', async ({ page }) => {
|
||||
// Open Note B and rename it
|
||||
await openNote(page, 'Note B')
|
||||
|
||||
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const input = page.locator('input').first()
|
||||
await expect(input).toBeVisible({ timeout: 2_000 })
|
||||
await input.fill('Note B Updated')
|
||||
await input.press('Enter')
|
||||
|
||||
// Wait for rename to complete (file to be moved)
|
||||
const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md')
|
||||
await expect(async () => {
|
||||
expect(fs.existsSync(newPath)).toBe(true)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
|
||||
// Verify alpha-project.md now references [[Note B Updated]] instead of [[Note B]]
|
||||
const alphaContent = fs.readFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), 'utf-8')
|
||||
expect(alphaContent).toContain('[[Note B Updated]]')
|
||||
expect(alphaContent).not.toContain('[[Note B]]')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Relationship display in inspector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('inspector shows relationships for note with wikilink fields', async ({ page }) => {
|
||||
// Open Note C which has Related to: [[Alpha Project]] and Topics: [[design]]
|
||||
await openNote(page, 'Note C')
|
||||
|
||||
// The DynamicRelationshipsPanel renders field labels like "Related to", "Topics"
|
||||
// as <span> elements. Verify that the relationship labels are visible in the inspector.
|
||||
await expect(page.getByText('Related to').first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Opening a note loads real file content from disk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('editor shows real file content from disk', async ({ page }) => {
|
||||
// Open Team Meeting
|
||||
await openNote(page, 'Team Meeting')
|
||||
|
||||
// Verify editor shows the actual file content — H1 heading from the file
|
||||
await expect(page.getByRole('heading', { name: 'Team Meeting', level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
@@ -43,7 +43,7 @@ test.describe('AI-created note visibility', () => {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
})
|
||||
|
||||
test('vault_changed + open_tab from MCP makes note visible and opens tab', async ({ page }) => {
|
||||
test.fixme('vault_changed + open_tab from MCP makes note visible and opens tab', async ({ page }) => {
|
||||
// Intercept list_vault API calls. On reload (after vault_changed),
|
||||
// the injected note will be included in the response.
|
||||
await page.route('**/api/vault/list*', async (route) => {
|
||||
|
||||
168
tests/smoke/blocknote-serializer-blank-lines.spec.ts
Normal file
168
tests/smoke/blocknote-serializer-blank-lines.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('BlockNote serializer blank lines fix', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('tight lists are serialized without blank lines between items', async ({ page }) => {
|
||||
// 1. Open the first note in the note list (mock content has bullet lists)
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
const firstNote = noteListContainer.locator('.cursor-pointer').first()
|
||||
await firstNote.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 2. Wait for the BlockNote editor to render
|
||||
const editorContainer = page.locator('.bn-editor')
|
||||
await expect(editorContainer).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 3. Type a character in the editor to trigger serialization
|
||||
await editorContainer.click()
|
||||
// Move cursor to end of first paragraph and type a space
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.type(' ')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// 4. Toggle to raw editor to see the serialized content
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 5. Read the raw editor content (CodeMirror textarea)
|
||||
const rawEditor = page.locator('[data-testid="raw-editor-codemirror"]')
|
||||
await expect(rawEditor).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Get the raw markdown content via the CodeMirror view
|
||||
const rawContent = await page.evaluate(() => {
|
||||
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
|
||||
if (!el) return ''
|
||||
// CodeMirror stores its content in the view
|
||||
const cm = el.querySelector('.cm-content')
|
||||
return cm?.textContent ?? ''
|
||||
})
|
||||
|
||||
// 6. Verify: bullet list items should NOT have blank lines between them
|
||||
// The mock content has bullet list items like "- First level item"
|
||||
// After BlockNote serialization, they should still be tight (no blank lines)
|
||||
expect(rawContent).toBeTruthy()
|
||||
|
||||
// Check there are no patterns like "* item\n\n* item" or "- item\n\n- item"
|
||||
// in the serialized output. We look for list markers since the raw editor
|
||||
// shows the serialized content.
|
||||
const lines = rawContent.split('\n')
|
||||
for (let i = 0; i < lines.length - 2; i++) {
|
||||
const line = lines[i]
|
||||
const nextLine = lines[i + 1]
|
||||
const lineAfter = lines[i + 2]
|
||||
// If current line is a list item and line after blank is also a list item,
|
||||
// that's the bug
|
||||
if (/^[-*+]\s/.test(line.trim()) && nextLine?.trim() === '' && /^[-*+]\s/.test(lineAfter?.trim() ?? '')) {
|
||||
throw new Error(
|
||||
`Found blank line between list items at line ${i + 1}:\n` +
|
||||
` "${line}"\n (blank)\n "${lineAfter}"\n` +
|
||||
'This indicates the serializer is adding extra blank lines between list items.'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('saving without editing does not add whitespace changes', async ({ page }) => {
|
||||
// 1. Open the first note
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
const firstNote = noteListContainer.locator('.cursor-pointer').first()
|
||||
await firstNote.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 2. Wait for BlockNote to load the note
|
||||
const editorContainer = page.locator('.bn-editor')
|
||||
await expect(editorContainer).toBeVisible({ timeout: 5000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 3. Toggle to raw mode to read the note content BEFORE any edits
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const rawEditor = page.locator('[data-testid="raw-editor-codemirror"]')
|
||||
await expect(rawEditor).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const contentBefore = await page.evaluate(() => {
|
||||
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
|
||||
const cm = el?.querySelector('.cm-content')
|
||||
return cm?.textContent ?? ''
|
||||
})
|
||||
|
||||
// 4. Toggle back to WYSIWYG, make no edits, then toggle raw again
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Just click the editor without typing
|
||||
await page.locator('.bn-editor').click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Toggle raw again to compare
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const contentAfter = await page.evaluate(() => {
|
||||
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
|
||||
const cm = el?.querySelector('.cm-content')
|
||||
return cm?.textContent ?? ''
|
||||
})
|
||||
|
||||
// 5. Content should be identical (no extra blank lines added)
|
||||
expect(contentAfter).toBe(contentBefore)
|
||||
})
|
||||
|
||||
test('headings do not have extra blank lines added after them', async ({ page }) => {
|
||||
// 1. Open the first note
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
const firstNote = noteListContainer.locator('.cursor-pointer').first()
|
||||
await firstNote.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const editorContainer = page.locator('.bn-editor')
|
||||
await expect(editorContainer).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// 2. Type a character to trigger serialization
|
||||
await editorContainer.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.type(' ')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// 3. Toggle raw editor
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const rawContent = await page.evaluate(() => {
|
||||
const el = document.querySelector('[data-testid="raw-editor-codemirror"]')
|
||||
const cm = el?.querySelector('.cm-content')
|
||||
return cm?.textContent ?? ''
|
||||
})
|
||||
|
||||
// 4. Verify: no heading is followed by more than one blank line
|
||||
const lines = rawContent.split('\n')
|
||||
for (let i = 0; i < lines.length - 2; i++) {
|
||||
if (/^#{1,6}\s/.test(lines[i].trim())) {
|
||||
// A heading followed by two consecutive blank lines is the bug
|
||||
if (lines[i + 1]?.trim() === '' && lines[i + 2]?.trim() === '') {
|
||||
throw new Error(
|
||||
`Found multiple blank lines after heading at line ${i + 1}:\n` +
|
||||
` "${lines[i]}"\n (blank)\n (blank)\n` +
|
||||
'Headings should have at most one blank line after them.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user