Compare commits
24 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e305c29e6e | ||
|
|
5b1804e5e1 | ||
|
|
2c5cfe2923 | ||
|
|
35144aedfb | ||
|
|
4d7252c78f | ||
|
|
eb9a3d889f | ||
|
|
6d2988b722 | ||
|
|
a33a000c57 | ||
|
|
f0b456bb8c | ||
|
|
b489fa8e3e | ||
|
|
1a92b4694c | ||
|
|
6b9ff9a4a2 | ||
|
|
97d2182c0e | ||
|
|
80baa74175 | ||
|
|
f71e6d07e7 | ||
|
|
b8a0702e3c | ||
|
|
c48f337c4d | ||
|
|
b32d46f482 | ||
|
|
fd100e1c3b | ||
|
|
249db95c9e | ||
|
|
3c27b63908 | ||
|
|
a246de4483 | ||
|
|
2793024904 | ||
|
|
f0ef9cacec |
@@ -1 +1 @@
|
||||
24855
|
||||
45402
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -60,6 +60,9 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Bundle MCP server resources (required by Tauri build)
|
||||
run: node scripts/bundle-mcp-server.mjs
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,3 +42,6 @@ final_selection.py
|
||||
.claude-done
|
||||
.claude-blocked
|
||||
src-tauri/target
|
||||
|
||||
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
|
||||
src-tauri/resources/
|
||||
|
||||
1
design/archived-note-indicator.pen
Normal file
1
design/archived-note-indicator.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/sort-picker-properties.pen
Normal file
1
design/sort-picker-properties.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/trashed-note-editor.pen
Normal file
1
design/trashed-note-editor.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"id":"trashed-banner-frame","type":"frame","name":"Trashed Note Banner","x":0,"y":0,"width":720,"height":340,"fill":"#FFFFFF","cornerRadius":[8,8,8,8],"children":[{"id":"breadcrumb","type":"frame","name":"Breadcrumb Bar","width":720,"height":45,"fill":"#FFFFFF","layout":"horizontal","mainAxisAlignment":"space-between","crossAxisAlignment":"center","padding":16,"children":[{"id":"breadcrumb-left","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","children":[{"id":"type-label","type":"text","content":"Note","fontSize":12,"textColor":"#6B7280"},{"id":"sep","type":"text","content":"›","fontSize":12,"textColor":"#6B7280"},{"id":"title","type":"text","content":"My Trashed Note","fontSize":12,"fontWeight":"600","textColor":"#1F2937"},{"id":"dot","type":"text","content":"·","fontSize":12,"textColor":"#6B7280"},{"id":"words","type":"text","content":"1,234 words","fontSize":12,"textColor":"#6B7280"}]},{"id":"breadcrumb-right","type":"frame","layout":"horizontal","gap":12,"crossAxisAlignment":"center","children":[{"id":"restore-icon","type":"text","content":"↺","fontSize":14,"textColor":"#6B7280"},{"id":"inspector-icon","type":"text","content":"⚙","fontSize":14,"textColor":"#6B7280"}]}]},{"id":"banner","type":"frame","name":"Trashed Note Banner","width":720,"height":32,"fill":"#FEF2F2","layout":"horizontal","crossAxisAlignment":"center","gap":8,"padding":16,"children":[{"id":"trash-icon","type":"text","content":"🗑","fontSize":12,"textColor":"#DC2626"},{"id":"banner-text","type":"text","content":"This note is in the Trash","fontSize":12,"textColor":"#6B7280","width":400},{"id":"restore-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"restore-icon-2","type":"text","content":"↺","fontSize":11,"textColor":"#2563EB"},{"id":"restore-label","type":"text","content":"Restore","fontSize":11,"textColor":"#2563EB"}]},{"id":"delete-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"delete-icon","type":"text","content":"🗑","fontSize":11,"textColor":"#DC2626"},{"id":"delete-label","type":"text","content":"Delete permanently","fontSize":11,"textColor":"#DC2626"}]}]},{"id":"editor-area","type":"frame","name":"Editor (read-only, muted)","width":720,"height":260,"fill":"#FAFAFA","padding":32,"children":[{"id":"h1","type":"text","content":"My Trashed Note","fontSize":24,"fontWeight":"700","textColor":"#9CA3AF"},{"id":"p1","type":"text","content":"This is the content of a trashed note. The editor is non-editable.","fontSize":14,"textColor":"#9CA3AF","y":40},{"id":"p2","type":"text","content":"Navigation and copy still work, but typing and editing are disabled.","fontSize":14,"textColor":"#9CA3AF","y":64}]}]}],"variables":{}}
|
||||
281
design/vault-management-remove.pen
Normal file
281
design/vault-management-remove.pen
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_menu_remove",
|
||||
"name": "Vault Menu — Remove from List",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 380,
|
||||
"height": 320,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24, 24, 24],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vault_menu_title",
|
||||
"content": "Vault Menu — Remove Action",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_menu_dropdown",
|
||||
"name": "Vault Dropdown",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"layout": "vertical",
|
||||
"padding": [4, 4, 4, 4],
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_active",
|
||||
"name": "Active Vault Item",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"fill": "#EBEBEA",
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_active_left",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{ "type": "text", "id": "vault_check", "content": "✓", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "vault_label_active", "content": "My Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "remove_btn_active",
|
||||
"name": "Remove Button",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"cornerRadius": [3, 3, 3, 3],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "x_icon_active", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_other",
|
||||
"name": "Other Vault Item",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_other_left",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{ "type": "text", "id": "vault_spacer", "content": " ", "fill": "transparent", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "vault_label_other", "content": "Work Vault", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "remove_btn_other",
|
||||
"name": "Remove Button",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"cornerRadius": [3, 3, 3, 3],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "x_icon_other", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_separator",
|
||||
"name": "Separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_open_folder",
|
||||
"name": "Open Local Folder",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{ "type": "text", "id": "folder_icon", "content": "📁", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "open_folder_label", "content": "Open local folder", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vault_menu_note",
|
||||
"content": "× button removes vault from list without deleting files.\nAvailable when 2+ vaults in list.",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmd_k_vault_commands",
|
||||
"name": "Cmd+K — Vault Commands",
|
||||
"x": 420,
|
||||
"y": 0,
|
||||
"width": 520,
|
||||
"height": 320,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24, 24, 24],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cmdk_title",
|
||||
"content": "Command Palette — Vault Commands",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_palette",
|
||||
"name": "Command Palette",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"layout": "vertical",
|
||||
"padding": [0, 0, 0, 0],
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_search",
|
||||
"name": "Search Input",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 16, 0, 16],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmdk_search_text", "content": "vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_group_header",
|
||||
"name": "Settings Group",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "group_label", "content": "Settings", "fill": "#B4B4B4", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_open_vault",
|
||||
"name": "Open Vault Command",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_open_vault", "content": "Open Vault…", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_remove_vault",
|
||||
"name": "Remove Vault Command (highlighted)",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"fill": "#E8F4FE",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_remove_vault", "content": "Remove Vault from List", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_restore_gs",
|
||||
"name": "Restore Getting Started Command",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_restore_gs", "content": "Restore Getting Started Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cmdk_note",
|
||||
"content": "• 'Remove Vault from List' removes active vault (disabled if only 1 vault)\n• 'Restore Getting Started Vault' shown when GS vault is hidden\n• Both accessible via Cmd+K search with 'vault' keyword",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -231,3 +231,30 @@ test('full create note flow', async ({ page }) => {
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
|
||||
})
|
||||
|
||||
// --- Flow 8: Wiki-link navigation ---
|
||||
|
||||
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
|
||||
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
|
||||
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify we opened the right note
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
|
||||
|
||||
// Click the wikilink — use mouse.click to fire real mousedown
|
||||
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
|
||||
await expect(wikilink).toBeVisible()
|
||||
const box = await wikilink.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// New tab should open with the target note active
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
|
||||
|
||||
// Editor should show the target note's content
|
||||
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'coverage']),
|
||||
globalIgnores(['dist', 'coverage', 'src-tauri/resources/', 'src-tauri/target/']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
@@ -64,6 +65,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
|
||||
708
pnpm-lock.yaml
generated
708
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,5 @@
|
||||
packages:
|
||||
- mcp-server
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
45
scripts/bundle-mcp-server.mjs
Normal file
45
scripts/bundle-mcp-server.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Bundle the mcp-server Node.js files into self-contained CJS bundles
|
||||
* that can be shipped as Tauri resources inside the .app bundle.
|
||||
*
|
||||
* Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js}
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const SRC = join(ROOT, 'mcp-server')
|
||||
const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server')
|
||||
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
// Tell Node.js that this directory contains CJS bundles, even if the
|
||||
// root package.json declares "type": "module".
|
||||
writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' }))
|
||||
|
||||
const shared = {
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
// Mark optional native bindings as external — ws works fine without them
|
||||
external: ['bufferutil', 'utf-8-validate'],
|
||||
logLevel: 'warning',
|
||||
}
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'index.js')],
|
||||
outfile: join(OUT, 'index.js'),
|
||||
})
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'ws-bridge.js')],
|
||||
outfile: join(OUT, 'ws-bridge.js'),
|
||||
})
|
||||
|
||||
console.log('mcp-server bundled → src-tauri/resources/mcp-server/')
|
||||
@@ -9,6 +9,7 @@ pub mod search;
|
||||
pub mod settings;
|
||||
pub mod theme;
|
||||
pub mod vault;
|
||||
pub mod vault_list;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
@@ -24,6 +25,7 @@ use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
use theme::{ThemeFile, VaultSettings};
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
use vault_list::VaultList;
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
@@ -114,10 +116,7 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
|
||||
#[tauri::command]
|
||||
fn get_build_number() -> String {
|
||||
{
|
||||
let n = std::env::var("BUILD_NUMBER").unwrap_or_else(|_| "0".to_string());
|
||||
format!("b{}", n)
|
||||
}
|
||||
format!("b{}", env!("BUILD_NUMBER"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -207,6 +206,12 @@ fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -277,6 +282,16 @@ fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_vault_list() -> Result<VaultList, String> {
|
||||
vault_list::load_vault_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
@@ -460,6 +475,22 @@ mod tests {
|
||||
"expected 'b' prefix, got: {}",
|
||||
result
|
||||
);
|
||||
assert_ne!(result, "b0", "build number should not fall back to 0");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_ws_bridge(app: &mut tauri::App) {
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,23 +524,7 @@ pub fn run() {
|
||||
}
|
||||
|
||||
run_startup_tasks();
|
||||
|
||||
// Spawn the MCP WebSocket bridge for the default vault
|
||||
{
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
spawn_ws_bridge(app);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -535,12 +550,15 @@ pub fn run() {
|
||||
save_image,
|
||||
copy_image_to_vault,
|
||||
purge_trash,
|
||||
delete_note,
|
||||
migrate_is_a_to_type,
|
||||
batch_archive_notes,
|
||||
batch_trash_notes,
|
||||
get_settings,
|
||||
update_menu_state,
|
||||
save_settings,
|
||||
load_vault_list,
|
||||
save_vault_list,
|
||||
github_list_repos,
|
||||
github_create_repo,
|
||||
clone_repo,
|
||||
|
||||
@@ -27,10 +27,12 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
|
||||
// On macOS the exe lives at Contents/MacOS/<binary>.
|
||||
// Resources are placed at Contents/Resources/ by Tauri.
|
||||
let release_path = exe
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("mcp-server"))
|
||||
.map(|p| p.join("Resources").join("mcp-server"))
|
||||
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
|
||||
if release_path.join("ws-bridge.js").exists() {
|
||||
return Ok(release_path);
|
||||
|
||||
@@ -71,6 +71,36 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
save_settings_at(&settings_path()?, settings)
|
||||
}
|
||||
|
||||
fn last_vault_file() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn set_last_vault_at(path: &PathBuf, vault_path: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
fs::write(path, vault_path.trim())
|
||||
.map_err(|e| format!("Failed to write last vault path: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_last_vault() -> Option<String> {
|
||||
last_vault_file().ok().and_then(|p| get_last_vault_at(&p))
|
||||
}
|
||||
|
||||
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
|
||||
set_last_vault_at(&last_vault_file()?, vault_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -199,4 +229,65 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_vault_returns_none_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
assert!(get_last_vault_at(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_and_get_last_vault_roundtrip() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/MyVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/MyVault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_trims_whitespace() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, " /Users/test/Vault ").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_vault_returns_none_for_empty_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
fs::write(&path, " \n ").unwrap();
|
||||
assert!(get_last_vault_at(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/Vault").unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_overwrites_previous() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/OldVault").unwrap();
|
||||
set_last_vault_at(&path, "/Users/test/NewVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/NewVault")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ 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 = 2;
|
||||
const CACHE_VERSION: u32 = 4;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
#[serde(default = "default_cache_version")]
|
||||
version: u32,
|
||||
/// The vault path when the cache was written. Used to detect stale caches
|
||||
/// from a different machine or a moved vault directory.
|
||||
#[serde(default)]
|
||||
vault_path: String,
|
||||
commit_hash: String,
|
||||
entries: Vec<VaultEntry>,
|
||||
}
|
||||
@@ -77,7 +81,7 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
|
||||
// Use ls-files for untracked files so that newly-seeded directories are picked up
|
||||
// as individual files rather than as a single "?? dirname/" entry.
|
||||
let uncommitted = git_uncommitted_new_files(vault);
|
||||
let uncommitted = git_uncommitted_files(vault);
|
||||
// Also include modified-but-unstaged files via status --porcelain.
|
||||
let modified = run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
@@ -92,17 +96,9 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
files
|
||||
}
|
||||
|
||||
fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
|
||||
// Use ls-files to enumerate untracked files individually — git status --porcelain shows
|
||||
// whole untracked directories (e.g. "?? theme/") rather than individual files inside them,
|
||||
// so newly-seeded directories would never appear in results.
|
||||
run_git(vault, &["ls-files", "--others", "--exclude-standard"])
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.filter(|line| line.ends_with(".md"))
|
||||
.map(|line| line.to_string())
|
||||
.collect()
|
||||
})
|
||||
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
|
||||
run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -115,6 +111,7 @@ fn write_cache(vault: &Path, cache: &VaultCache) {
|
||||
if let Ok(data) = serde_json::to_string(cache) {
|
||||
let _ = fs::write(cache_path(vault), data);
|
||||
}
|
||||
ensure_cache_excluded(vault);
|
||||
}
|
||||
|
||||
/// Normalize an absolute path to a relative path for comparison with git output.
|
||||
@@ -143,6 +140,23 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
|
||||
/// This prevents the cache (which contains machine-specific absolute paths)
|
||||
/// from being committed and causing stale-path bugs on cloned vaults.
|
||||
fn ensure_cache_excluded(vault: &Path) {
|
||||
let exclude_path = vault.join(".git/info/exclude");
|
||||
let entry = ".laputa-cache.json";
|
||||
if let Ok(content) = fs::read_to_string(&exclude_path) {
|
||||
if content.lines().any(|line| line.trim() == entry) {
|
||||
return;
|
||||
}
|
||||
let separator = if content.ends_with('\n') { "" } else { "\n" };
|
||||
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
|
||||
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
|
||||
let _ = fs::write(&exclude_path, format!("{entry}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort entries by modified_at descending and write the cache.
|
||||
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
@@ -150,6 +164,7 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
vault,
|
||||
&VaultCache {
|
||||
version: CACHE_VERSION,
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: hash,
|
||||
entries: entries.clone(),
|
||||
},
|
||||
@@ -157,23 +172,19 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
entries
|
||||
}
|
||||
|
||||
/// Handle same-commit cache hit: add any uncommitted new files.
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
let new_files = git_uncommitted_new_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
let existing: std::collections::HashSet<String> = entries
|
||||
.iter()
|
||||
.map(|e| to_relative_path(&e.path, vault))
|
||||
.collect();
|
||||
|
||||
let new_entries = parse_files_at(vault, &new_files);
|
||||
for entry in new_entries {
|
||||
let rel = to_relative_path(&entry.path, vault);
|
||||
if !existing.contains(&rel) {
|
||||
entries.push(entry);
|
||||
}
|
||||
let changed = git_uncommitted_files(vault);
|
||||
if changed.is_empty() {
|
||||
return cache.entries;
|
||||
}
|
||||
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
let mut entries: Vec<VaultEntry> = cache
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
finalize_and_cache(vault, entries, cache.commit_hash)
|
||||
}
|
||||
|
||||
@@ -212,7 +223,10 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
};
|
||||
|
||||
if let Some(cache) = load_cache(vault_path) {
|
||||
if cache.version != CACHE_VERSION {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
let cache_stale = cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
|
||||
if cache_stale {
|
||||
let entries = scan_vault(vault_path)?;
|
||||
return Ok(finalize_and_cache(vault_path, entries, current_hash));
|
||||
}
|
||||
@@ -300,6 +314,71 @@ mod tests {
|
||||
assert_eq!(entries2[0].title, "Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_invalidates_stale_vault_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nContent.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Build cache normally
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0]
|
||||
.path
|
||||
.starts_with(&vault.to_string_lossy().as_ref()),
|
||||
"Entry path should start with vault path"
|
||||
);
|
||||
|
||||
// Tamper with cache to simulate a clone from a different machine
|
||||
let cache_file = cache_path(vault);
|
||||
let cache_data = fs::read_to_string(&cache_file).unwrap();
|
||||
let tampered = cache_data.replace(
|
||||
&vault.to_string_lossy().as_ref(),
|
||||
"/Users/other-machine/OtherVault",
|
||||
);
|
||||
fs::write(&cache_file, tampered).unwrap();
|
||||
|
||||
// Rescanning should invalidate the stale cache and produce correct paths
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert!(
|
||||
entries2[0]
|
||||
.path
|
||||
.starts_with(&vault.to_string_lossy().as_ref()),
|
||||
"After stale-cache invalidation, paths should use the current vault path, got: {}",
|
||||
entries2[0].path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_incremental_different_commit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -360,9 +439,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_new_untracked_directory() {
|
||||
// Regression test: git status --porcelain shows "?? theme/" for an untracked
|
||||
// directory, not individual files. scan_vault_cached must still pick them up.
|
||||
fn test_update_same_commit_picks_up_modified_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
@@ -372,17 +449,18 @@ mod tests {
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "t@t.com"])
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "T"])
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nContent.");
|
||||
// Commit a type note without sidebar label
|
||||
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
@@ -394,37 +472,73 @@ mod tests {
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Build initial cache
|
||||
// Prime the cache (same commit hash)
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].sidebar_label, None);
|
||||
|
||||
// User edits the type note to add sidebar label (uncommitted)
|
||||
create_test_file(
|
||||
vault,
|
||||
"type/news.md",
|
||||
"---\ntype: Type\nsidebar label: News\n---\n# News\n",
|
||||
);
|
||||
|
||||
// Reload with same git HEAD — must pick up the modification
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(
|
||||
entries2[0].sidebar_label,
|
||||
Some("News".to_string()),
|
||||
"sidebarLabel must reflect the uncommitted edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_file_still_added() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
// Add a new DIRECTORY with files (simulating seed_vault_themes) — NOT committed
|
||||
create_test_file(
|
||||
vault,
|
||||
"theme/default.md",
|
||||
"---\nIs A: Theme\n---\n# Default Theme\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"theme/dark.md",
|
||||
"---\nIs A: Theme\n---\n# Dark Theme\n",
|
||||
);
|
||||
// Create new untracked file
|
||||
create_test_file(vault, "new-note.md", "# New Note\n");
|
||||
|
||||
// Re-scan — should find the new untracked files inside the untracked directory
|
||||
// Cache still same commit — new untracked file must appear
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(
|
||||
entries2.len(),
|
||||
3,
|
||||
"Should include theme files from untracked directory"
|
||||
);
|
||||
assert_eq!(entries2.len(), 2);
|
||||
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
|
||||
assert!(
|
||||
titles.contains(&"Default Theme"),
|
||||
"Should find default.md in untracked theme/ dir"
|
||||
);
|
||||
assert!(
|
||||
titles.contains(&"Dark Theme"),
|
||||
"Should find dark.md in untracked theme/ dir"
|
||||
);
|
||||
assert!(titles.contains(&"Existing"));
|
||||
assert!(titles.contains(&"New Note"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
/// Default location for the Getting Started vault.
|
||||
pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
dirs::document_dir()
|
||||
.map(|d| d.join("Laputa"))
|
||||
.map(|d| d.join("Getting Started"))
|
||||
.ok_or_else(|| "Could not determine Documents directory".to_string())
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ mod tests {
|
||||
let path = default_vault_path().unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.contains("Documents"));
|
||||
assert!(path_str.ends_with("Laputa"));
|
||||
assert!(path_str.ends_with("Getting Started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,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::{rename_note, RenameResult};
|
||||
pub use trash::purge_trash;
|
||||
pub use trash::{delete_note, purge_trash};
|
||||
|
||||
use parsing::{
|
||||
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
|
||||
@@ -75,6 +75,10 @@ pub struct VaultEntry {
|
||||
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
|
||||
#[serde(rename = "outgoingLinks", default)]
|
||||
pub outgoing_links: Vec<String>,
|
||||
/// Custom scalar frontmatter properties (non-relationship, non-structural).
|
||||
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
@@ -200,6 +204,45 @@ fn extract_relationships(
|
||||
relationships
|
||||
}
|
||||
|
||||
/// Additional keys to skip when extracting custom properties.
|
||||
/// These are already first-class fields on VaultEntry, so including them
|
||||
/// in `properties` would duplicate information.
|
||||
const PROPERTY_EXTRA_SKIP: &[&str] = &["belongs to", "related to", "owner"];
|
||||
|
||||
/// Extract custom scalar properties from raw YAML frontmatter.
|
||||
/// Captures string, number, and boolean values that are not structural fields
|
||||
/// and do not contain wikilinks. Arrays and objects are excluded.
|
||||
fn extract_properties(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower))
|
||||
|| PROPERTY_EXTRA_SKIP
|
||||
.iter()
|
||||
.any(|k| k.eq_ignore_ascii_case(&lower))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
if !contains_wikilink(s) {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
properties
|
||||
}
|
||||
|
||||
/// Infer entity type from a parent folder name.
|
||||
fn infer_type_from_folder(folder: &str) -> String {
|
||||
match folder {
|
||||
@@ -243,19 +286,24 @@ fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
|
||||
.or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s)))
|
||||
}
|
||||
|
||||
/// Extract frontmatter and relationships from parsed gray_matter data.
|
||||
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
|
||||
fn extract_fm_and_rels(
|
||||
data: Option<gray_matter::Pod>,
|
||||
) -> (Frontmatter, HashMap<String, Vec<String>>) {
|
||||
) -> (
|
||||
Frontmatter,
|
||||
HashMap<String, Vec<String>>,
|
||||
HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let hash = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => map,
|
||||
_ => return (Frontmatter::default(), HashMap::new()),
|
||||
_ => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
};
|
||||
let json_map: HashMap<String, serde_json::Value> =
|
||||
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
|
||||
(
|
||||
parse_frontmatter(&json_map),
|
||||
extract_relationships(&json_map),
|
||||
extract_properties(&json_map),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -282,7 +330,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
let (frontmatter, mut relationships) = extract_fm_and_rels(parsed.data);
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data);
|
||||
|
||||
let title = extract_title(&parsed.content, &filename);
|
||||
let snippet = extract_snippet(&content);
|
||||
@@ -341,6 +389,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
template: frontmatter.template,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1134,6 +1183,94 @@ References:
|
||||
assert!(entry.relationships.get("template").is_none());
|
||||
}
|
||||
|
||||
// --- custom properties tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_scalar_values() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Priority: High
|
||||
Rating: 5
|
||||
Due date: 2026-06-15
|
||||
Reviewed: true
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "project/test.md", content);
|
||||
let expected: HashMap<String, serde_json::Value> = [
|
||||
("Priority".into(), serde_json::Value::String("High".into())),
|
||||
("Rating".into(), serde_json::json!(5)),
|
||||
(
|
||||
"Due date".into(),
|
||||
serde_json::Value::String("2026-06-15".into()),
|
||||
),
|
||||
("Reviewed".into(), serde_json::Value::Bool(true)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(entry.properties, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_structural_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: Luca
|
||||
Cadence: Weekly
|
||||
Archived: false
|
||||
Priority: High
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "project/test.md", content);
|
||||
// Only Priority should survive — all others are structural
|
||||
assert_eq!(entry.properties.len(), 1);
|
||||
assert_eq!(
|
||||
entry.properties.get("Priority").and_then(|v| v.as_str()),
|
||||
Some("High")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Mentor: "[[person/alice]]"
|
||||
Company: Acme Corp
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert!(entry.properties.get("Mentor").is_none());
|
||||
assert_eq!(
|
||||
entry.properties.get("Company").and_then(|v| v.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_arrays() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Tags:
|
||||
- productivity
|
||||
- writing
|
||||
Company: Acme Corp
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert!(entry.properties.get("Tags").is_none());
|
||||
assert_eq!(
|
||||
entry.properties.get("Company").and_then(|v| v.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -42,6 +42,21 @@ fn try_purge_file(path: &Path) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permanently delete a single note file.
|
||||
/// Returns the deleted path on success, or an error if the file doesn't exist.
|
||||
pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
let file = Path::new(path);
|
||||
if !file.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
if !file.is_file() {
|
||||
return Err(format!("Path is not a file: {}", path));
|
||||
}
|
||||
fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?;
|
||||
log::info!("Permanently deleted note: {}", path);
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -95,6 +110,28 @@ mod tests {
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_note_removes_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"doomed.md",
|
||||
"---\ntitle: Doomed\n---\n# Doomed\n",
|
||||
);
|
||||
let path = dir.path().join("doomed.md");
|
||||
assert!(path.exists());
|
||||
let result = delete_note(path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_note_nonexistent_file() {
|
||||
let result = delete_note("/nonexistent/path/that/does/not/exist.md");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_deletes_old_trashed_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
170
src-tauri/src/vault_list.rs
Normal file
170
src-tauri/src/vault_list.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VaultEntry {
|
||||
pub label: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VaultList {
|
||||
pub vaults: Vec<VaultEntry>,
|
||||
pub active_vault: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hidden_defaults: Vec<String>,
|
||||
}
|
||||
|
||||
fn vault_list_path() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("vaults.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
|
||||
if !path.exists() {
|
||||
return Ok(VaultList::default());
|
||||
}
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e))
|
||||
}
|
||||
|
||||
fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(list)
|
||||
.map_err(|e| format!("Failed to serialize vault list: {}", e))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e))
|
||||
}
|
||||
|
||||
pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
load_at(&vault_list_path()?)
|
||||
}
|
||||
|
||||
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
|
||||
save_at(&vault_list_path()?, list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn save_and_reload(list: &VaultList) -> VaultList {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("vaults.json");
|
||||
save_at(&path, list).unwrap();
|
||||
load_at(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_vault_list_is_empty() {
|
||||
let vl = VaultList::default();
|
||||
assert!(vl.vaults.is_empty());
|
||||
assert!(vl.active_vault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_preserves_data() {
|
||||
let list = VaultList {
|
||||
vaults: vec![
|
||||
VaultEntry {
|
||||
label: "My Vault".to_string(),
|
||||
path: "/Users/luca/Laputa".to_string(),
|
||||
},
|
||||
VaultEntry {
|
||||
label: "Work".to_string(),
|
||||
path: "/Users/luca/Work".to_string(),
|
||||
},
|
||||
],
|
||||
active_vault: Some("/Users/luca/Laputa".to_string()),
|
||||
hidden_defaults: vec![],
|
||||
};
|
||||
let loaded = save_and_reload(&list);
|
||||
assert_eq!(loaded.vaults.len(), 2);
|
||||
assert_eq!(loaded.vaults[0].label, "My Vault");
|
||||
assert_eq!(loaded.vaults[0].path, "/Users/luca/Laputa");
|
||||
assert_eq!(loaded.vaults[1].label, "Work");
|
||||
assert_eq!(loaded.active_vault.as_deref(), Some("/Users/luca/Laputa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_default_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nonexistent.json");
|
||||
let result = load_at(&path).unwrap();
|
||||
assert!(result.vaults.is_empty());
|
||||
assert!(result.active_vault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_error_for_malformed_json() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("bad.json");
|
||||
fs::write(&path, "not valid json{{{").unwrap();
|
||||
let err = load_at(&path).unwrap_err();
|
||||
assert!(err.contains("Failed to parse vault list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("vaults.json");
|
||||
let list = VaultList {
|
||||
vaults: vec![VaultEntry {
|
||||
label: "Test".to_string(),
|
||||
path: "/tmp/test".to_string(),
|
||||
}],
|
||||
active_vault: None,
|
||||
hidden_defaults: vec![],
|
||||
};
|
||||
save_at(&path, &list).unwrap();
|
||||
assert!(path.exists());
|
||||
let loaded = load_at(&path).unwrap();
|
||||
assert_eq!(loaded.vaults.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_list_path_returns_ok() {
|
||||
let result = vault_list_path();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vault_list_roundtrip() {
|
||||
let list = VaultList::default();
|
||||
let loaded = save_and_reload(&list);
|
||||
assert!(loaded.vaults.is_empty());
|
||||
assert!(loaded.active_vault.is_none());
|
||||
assert!(loaded.hidden_defaults.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_defaults_roundtrip() {
|
||||
let list = VaultList {
|
||||
vaults: vec![],
|
||||
active_vault: None,
|
||||
hidden_defaults: vec!["/Users/luca/Documents/Getting Started".to_string()],
|
||||
};
|
||||
let loaded = save_and_reload(&list);
|
||||
assert_eq!(loaded.hidden_defaults.len(), 1);
|
||||
assert_eq!(
|
||||
loaded.hidden_defaults[0],
|
||||
"/Users/luca/Documents/Getting Started"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_legacy_format_without_hidden_defaults() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("legacy.json");
|
||||
// Simulate old format without hidden_defaults field
|
||||
fs::write(&path, r#"{"vaults":[],"active_vault":null}"#).unwrap();
|
||||
let loaded = load_at(&path).unwrap();
|
||||
assert!(loaded.hidden_defaults.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
"identifier": "club.refactoring.laputa",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5201",
|
||||
"devUrl": "http://localhost:5202",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
@@ -37,6 +37,9 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"resources/mcp-server/**/*": "mcp-server/"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -71,7 +71,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
get_default_vault_path: '/Users/mock/Documents/Laputa',
|
||||
get_default_vault_path: '/Users/mock/Documents/Getting Started',
|
||||
list_themes: [],
|
||||
get_vault_settings: { theme: null },
|
||||
}
|
||||
|
||||
50
src/App.tsx
50
src/App.tsx
@@ -33,6 +33,8 @@ import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import { extractOutgoingLinks } from './utils/wikilinks'
|
||||
import type { SidebarSelection } from './types'
|
||||
import './App.css'
|
||||
@@ -248,6 +250,18 @@ function App() {
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
})
|
||||
|
||||
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 gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
|
||||
const handleCreateType = useCallback((name: string) => {
|
||||
@@ -272,16 +286,32 @@ function App() {
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
const rawToggleRef = useRef<() => void>(() => {})
|
||||
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
|
||||
const diffToggleRef = useRef<() => void>(() => {})
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
const result = await updateActions.checkForUpdates()
|
||||
if (result === 'up-to-date') {
|
||||
setToastMessage("You're on the latest version")
|
||||
} else if (result === 'error') {
|
||||
setToastMessage('Could not check for updates')
|
||||
}
|
||||
// 'available' → UpdateBanner handles it automatically
|
||||
}, [updateActions, setToastMessage])
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
entries: vault.entries, allContent: vault.allContent,
|
||||
modifiedCount: vault.modifiedFiles.length, selection,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
@@ -293,6 +323,7 @@ function App() {
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onToggleDiff: () => diffToggleRef.current(),
|
||||
onToggleRawEditor: () => rawToggleRef.current(),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
@@ -313,11 +344,16 @@ function App() {
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
},
|
||||
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
||||
onCreateType: dialogs.openCreateType,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
onCheckForUpdates: handleCheckForUpdates,
|
||||
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
|
||||
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
|
||||
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
|
||||
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
})
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist
|
||||
@@ -355,7 +391,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -363,7 +399,7 @@ function App() {
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
</>
|
||||
@@ -397,6 +433,7 @@ function App() {
|
||||
vaultPath={resolvedPath}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
@@ -404,6 +441,7 @@ function App() {
|
||||
onSave={handleSave}
|
||||
onTitleSync={handleTitleSync}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={navHistory.canGoBack}
|
||||
canGoForward={navHistory.canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
@@ -414,7 +452,7 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onRemoveVault={vaultSwitcher.removeVault} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
|
||||
26
src/components/ArchivedNoteBanner.test.tsx
Normal file
26
src/components/ArchivedNoteBanner.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
|
||||
|
||||
describe('ArchivedNoteBanner', () => {
|
||||
it('renders archive icon and label', () => {
|
||||
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeTruthy()
|
||||
expect(screen.getByText('Archived')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders unarchive button with keyboard hint', () => {
|
||||
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
|
||||
const btn = screen.getByTestId('unarchive-btn')
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn.textContent).toContain('Unarchive')
|
||||
expect(btn.title).toBe('Unarchive (Cmd+E)')
|
||||
})
|
||||
|
||||
it('calls onUnarchive when button is clicked', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<ArchivedNoteBanner onUnarchive={onUnarchive} />)
|
||||
fireEvent.click(screen.getByTestId('unarchive-btn'))
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
48
src/components/ArchivedNoteBanner.tsx
Normal file
48
src/components/ArchivedNoteBanner.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Archive, ArrowUUpLeft } from '@phosphor-icons/react'
|
||||
|
||||
interface ArchivedNoteBannerProps {
|
||||
onUnarchive: () => void
|
||||
}
|
||||
|
||||
export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="archived-note-banner"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 16px',
|
||||
background: 'var(--muted)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
color: 'var(--muted-foreground)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Archive size={13} weight="bold" />
|
||||
<span>Archived</span>
|
||||
<button
|
||||
data-testid="unarchive-btn"
|
||||
onClick={onUnarchive}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginLeft: 'auto',
|
||||
padding: '2px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Unarchive (Cmd+E)"
|
||||
>
|
||||
<ArrowUUpLeft size={12} />
|
||||
Unarchive
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
BlockNoteView: ({ children }: { children?: React.ReactNode }) => <div data-testid="blocknote-view">{children}</div>,
|
||||
BlockNoteView: ({ children, editable }: { children?: React.ReactNode; editable?: boolean }) => <div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine/style.css', () => ({}))
|
||||
@@ -294,6 +294,41 @@ describe('Editor', () => {
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
mockEditor.insertBlocks.mockClear()
|
||||
})
|
||||
describe('trashed note behavior', () => {
|
||||
const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
const trashedTab = { entry: trashedEntry, content: mockContent }
|
||||
|
||||
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
|
||||
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
}
|
||||
|
||||
it('shows banner and read-only editor when note is trashed', () => {
|
||||
renderTrashed()
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
|
||||
})
|
||||
|
||||
it('does not show banner and sets editable for normal notes', () => {
|
||||
render(<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
})
|
||||
|
||||
it('calls onRestoreNote when banner restore is clicked', () => {
|
||||
const onRestoreNote = vi.fn()
|
||||
renderTrashed({ onRestoreNote })
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
|
||||
expect(onRestoreNote).toHaveBeenCalledWith(trashedEntry.path)
|
||||
})
|
||||
|
||||
it('calls onDeleteNote when banner delete is clicked', () => {
|
||||
const onDeleteNote = vi.fn()
|
||||
renderTrashed({ onDeleteNote })
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
|
||||
expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('wikilink autocomplete', () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ interface EditorProps {
|
||||
vaultPath?: string
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
@@ -65,16 +66,19 @@ interface EditorProps {
|
||||
isDarkTheme?: boolean
|
||||
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
|
||||
diffToggleRef?: React.MutableRefObject<() => void>
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
|
||||
}: {
|
||||
diffMode: boolean
|
||||
rawMode: boolean
|
||||
handleToggleDiff: () => void | Promise<void>
|
||||
handleToggleRaw: () => void
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
diffToggleRef?: React.MutableRefObject<() => void>
|
||||
}) {
|
||||
const handleToggleDiffExclusive = useCallback(async () => {
|
||||
if (!diffMode && rawMode) handleToggleRaw()
|
||||
@@ -90,6 +94,10 @@ function useEditorModeExclusion({
|
||||
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
|
||||
}, [rawToggleRef, handleToggleRawExclusive])
|
||||
|
||||
useEffect(() => {
|
||||
if (diffToggleRef) diffToggleRef.current = handleToggleDiffExclusive
|
||||
}, [diffToggleRef, handleToggleDiffExclusive])
|
||||
|
||||
return { handleToggleDiffExclusive, handleToggleRawExclusive }
|
||||
}
|
||||
|
||||
@@ -110,11 +118,12 @@ export const Editor = memo(function Editor({
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab, onContentChange, onSave, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
isDarkTheme,
|
||||
rawToggleRef,
|
||||
diffToggleRef,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -151,13 +160,12 @@ export const Editor = memo(function Editor({
|
||||
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
|
||||
|
||||
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
|
||||
})
|
||||
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
const showRightPanel = !!(showAIChat || !inspectorCollapsed)
|
||||
|
||||
return (
|
||||
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
|
||||
@@ -202,13 +210,14 @@ export const Editor = memo(function Editor({
|
||||
onEditorChange={handleEditorChange}
|
||||
onTrashNote={onTrashNote}
|
||||
onRestoreNote={onRestoreNote}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
}
|
||||
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
<EditorRightPanel
|
||||
showAIChat={showAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { TrashedNoteBanner } from './TrashedNoteBanner'
|
||||
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
@@ -34,6 +36,7 @@ interface EditorContentProps {
|
||||
onEditorChange?: () => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
@@ -97,7 +100,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
|
||||
function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
activeTab: Tab
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
|
||||
}) {
|
||||
const wordCount = countWords(activeTab.content)
|
||||
const path = activeTab.entry.path
|
||||
@@ -124,14 +127,38 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
)
|
||||
}
|
||||
|
||||
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed }: {
|
||||
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
|
||||
}) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
return (
|
||||
<>
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
|
||||
{showEditor && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditorContent({
|
||||
activeTab, isLoadingNewTab, entries, editor,
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
onDeleteNote,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
const isTrashed = activeTab?.entry.trashed ?? false
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||
@@ -141,14 +168,16 @@ export function EditorContent({
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
|
||||
{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} />
|
||||
</div>
|
||||
{activeTab && isTrashed && (
|
||||
<TrashedNoteBanner
|
||||
onRestore={() => breadcrumbProps.onRestoreNote?.(activeTab.entry.path)}
|
||||
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
|
||||
/>
|
||||
)}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
{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} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const mockEntries: VaultEntry[] = [
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
|
||||
@@ -65,6 +66,7 @@ const mockEntries: VaultEntry[] = [
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/matteo-cellini.md',
|
||||
@@ -91,6 +93,7 @@ const mockEntries: VaultEntry[] = [
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
|
||||
@@ -117,6 +120,7 @@ const mockEntries: VaultEntry[] = [
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic/software-development.md',
|
||||
@@ -143,6 +147,7 @@ const mockEntries: VaultEntry[] = [
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -364,6 +369,7 @@ describe('getSortComparator', () => {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -477,6 +483,7 @@ describe('NoteList sort controls', () => {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -643,6 +650,68 @@ describe('NoteList sort controls', () => {
|
||||
titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent)
|
||||
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
|
||||
})
|
||||
|
||||
it('shows custom properties with separator in sort dropdown', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'A', properties: { Priority: 'High', Rating: 5 } }),
|
||||
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-property:Company')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits separator when no custom properties exist', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sorts entries by custom property when selected', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Rating: 3 } }),
|
||||
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: { Rating: 1 } }),
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default: modified desc → A, B, C
|
||||
let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
|
||||
expect(titles).toEqual(['A', 'B', 'C'])
|
||||
|
||||
// Switch to Rating sort (asc by default for properties)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-property:Rating'))
|
||||
|
||||
// Rating asc: B(1), A(3), C(5)
|
||||
titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
|
||||
expect(titles).toEqual(['B', 'A', 'C'])
|
||||
})
|
||||
|
||||
it('pushes entries without the property to end when sorting by custom property', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Priority: 'High' } }),
|
||||
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: {} }),
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
|
||||
|
||||
// Asc: A(High), C(Low), B(null → end)
|
||||
const titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
|
||||
expect(titles).toEqual(['A', 'C', 'B'])
|
||||
})
|
||||
})
|
||||
|
||||
// --- Trash feature tests ---
|
||||
@@ -672,6 +741,7 @@ const trashedEntry: VaultEntry = {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const expiredTrashedEntry: VaultEntry = {
|
||||
@@ -699,6 +769,7 @@ const expiredTrashedEntry: VaultEntry = {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry]
|
||||
@@ -855,6 +926,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -996,6 +1068,33 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches entries by relative path suffix when absolute paths differ (cross-machine)', () => {
|
||||
// Simulate a cloned vault where cached entries have paths from a different machine
|
||||
const crossMachineEntries: VaultEntry[] = mockEntries.map((e) => ({
|
||||
...e,
|
||||
path: e.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'),
|
||||
}))
|
||||
const modifiedFromCurrentMachine = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Even though absolute paths differ, entries should match via relative path suffix
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message when modifiedFilesError is set', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows untracked (new) notes alongside modified notes in changes view', () => {
|
||||
const mixedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
@@ -1157,6 +1256,7 @@ const typeEntry: VaultEntry = {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const entriesWithType = [...mockEntries, typeEntry]
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
|
||||
getSortComparator,
|
||||
getSortComparator, extractSortableProperties,
|
||||
buildRelationshipGroups, filterEntries,
|
||||
relativeDate, getDisplayDate,
|
||||
loadSortPreferences, saveSortPreferences,
|
||||
@@ -26,6 +26,7 @@ interface NoteListProps {
|
||||
selectedNote: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
modifiedFilesError?: string | null
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
sidebarCollapsed?: boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
@@ -66,6 +67,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han
|
||||
}) {
|
||||
const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
|
||||
const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction))
|
||||
const customProperties = useMemo(() => extractSortableProperties(group.entries), [group.entries])
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
|
||||
@@ -74,7 +76,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han
|
||||
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
|
||||
</button>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} onChange={handleSortChange} />
|
||||
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} customProperties={customProperties} onChange={handleSortChange} />
|
||||
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
|
||||
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
</button>
|
||||
@@ -143,13 +145,13 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
|
||||
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
}
|
||||
|
||||
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
|
||||
function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
|
||||
searched: VaultEntry[]; query: string
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
|
||||
}) {
|
||||
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
|
||||
const emptyText = (isChangesView && changesError) ? `Failed to load changes: ${changesError}` : isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
|
||||
const hasHeader = isTrashView && expiredTrashCount > 0
|
||||
|
||||
if (searched.length === 0) {
|
||||
@@ -232,28 +234,39 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
|
||||
interface NoteListDataParams {
|
||||
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
|
||||
query: string; listSort: SortOption; listDirection: SortDirection
|
||||
modifiedPathSet: Set<string>
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
}
|
||||
|
||||
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet }: NoteListDataParams) {
|
||||
function isModifiedEntry(path: string, pathSet: Set<string>, suffixes: string[]): boolean {
|
||||
if (pathSet.has(path)) return true
|
||||
return suffixes.some((suffix) => path.endsWith(suffix))
|
||||
}
|
||||
|
||||
function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[]) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
return useMemo(() => {
|
||||
if (isEntityView) return []
|
||||
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
|
||||
return filterEntries(entries, selection)
|
||||
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
|
||||
}
|
||||
|
||||
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
|
||||
const typeDocument = useMemo(() => {
|
||||
if (selection.kind !== 'sectionGroup') return null
|
||||
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
|
||||
}, [selection, entries])
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
|
||||
|
||||
const searched = useMemo(() => {
|
||||
if (isEntityView) return []
|
||||
if (isChangesView) {
|
||||
const sorted = [...entries.filter((e) => modifiedPathSet.has(e.path))].sort(getSortComparator(listSort, listDirection))
|
||||
return filterByQuery(sorted, query)
|
||||
}
|
||||
const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection))
|
||||
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
|
||||
return filterByQuery(sorted, query)
|
||||
}, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet])
|
||||
}, [filteredEntries, listSort, listDirection, query])
|
||||
|
||||
const searchedGroups = useMemo(() => {
|
||||
if (!isEntityView) return []
|
||||
@@ -266,14 +279,14 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
[isTrashView, searched],
|
||||
)
|
||||
|
||||
return { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount }
|
||||
return { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount }
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
const defaultGetNoteStatus = (): NoteStatus => 'clean'
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -285,6 +298,14 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
[modifiedFiles],
|
||||
)
|
||||
|
||||
// Suffix patterns for cross-machine robustness: if the vault cache carried
|
||||
// stale absolute paths from another machine, fall back to matching by the
|
||||
// relative path suffix so the changes view stays in sync with the badge.
|
||||
const modifiedSuffixes = useMemo(
|
||||
() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath),
|
||||
[modifiedFiles],
|
||||
)
|
||||
|
||||
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
|
||||
() => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
|
||||
[getNoteStatus, modifiedFiles, modifiedPathSet],
|
||||
@@ -301,9 +322,18 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const query = search.trim().toLowerCase()
|
||||
const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
|
||||
const listSort = listConfig.option
|
||||
const listDirection = listConfig.direction
|
||||
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
|
||||
|
||||
// Compute custom properties and derive effective sort before sorting entries
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
|
||||
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
|
||||
const listSort = useMemo<SortOption>(() => {
|
||||
const opt = listConfig.option
|
||||
if (!opt.startsWith('property:')) return opt
|
||||
return customProperties.includes(opt.slice('property:'.length)) ? opt : 'modified'
|
||||
}, [listConfig.option, customProperties])
|
||||
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
|
||||
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
|
||||
const noteListKeyboard = useNoteListKeyboard({
|
||||
items: searched,
|
||||
@@ -381,7 +411,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
{resolveHeaderTitle(selection, typeDocument)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} onChange={handleSortChange} />}
|
||||
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} onChange={handleSortChange} />}
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
@@ -401,7 +431,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
{isEntityView && selection.kind === 'entity' ? (
|
||||
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
) : (
|
||||
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/event/retreat.md',
|
||||
@@ -66,6 +67,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/bob'],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/responsibility/grow-newsletter.md',
|
||||
@@ -69,6 +70,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/experiment/stock-screener.md',
|
||||
@@ -96,6 +98,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/procedure/weekly-essays.md',
|
||||
@@ -123,6 +126,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/topic/software-development.md',
|
||||
@@ -150,6 +154,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/topic/trading.md',
|
||||
@@ -177,6 +182,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/person/alice.md',
|
||||
@@ -204,6 +210,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/event/kickoff.md',
|
||||
@@ -231,6 +238,7 @@ const mockEntries: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -451,6 +459,7 @@ describe('Sidebar', () => {
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/type/book.md',
|
||||
@@ -478,6 +487,7 @@ describe('Sidebar', () => {
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/recipe/pasta.md',
|
||||
@@ -504,6 +514,7 @@ describe('Sidebar', () => {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/book/ddia.md',
|
||||
@@ -530,6 +541,7 @@ describe('Sidebar', () => {
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -576,6 +588,7 @@ describe('Sidebar', () => {
|
||||
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
@@ -616,6 +629,7 @@ describe('Sidebar', () => {
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// "Projects" should appear once (the built-in section), not twice
|
||||
@@ -632,6 +646,7 @@ describe('Sidebar', () => {
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: 'News', outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
|
||||
@@ -639,6 +654,7 @@ describe('Sidebar', () => {
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithLabel} selection={defaultSelection} onSelect={() => {}} />)
|
||||
@@ -656,6 +672,7 @@ describe('Sidebar', () => {
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: 'Contacts', outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithBuiltInOverride} selection={defaultSelection} onSelect={() => {}} />)
|
||||
@@ -776,6 +793,7 @@ describe('Sidebar', () => {
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
|
||||
@@ -783,6 +801,7 @@ describe('Sidebar', () => {
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
|
||||
@@ -790,6 +809,7 @@ describe('Sidebar', () => {
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -815,4 +835,61 @@ describe('Sidebar', () => {
|
||||
expect(dragHandles.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename section via context menu', () => {
|
||||
it('shows Rename section option in context menu on right-click', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
expect(screen.getByText('Rename section…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Customize icon option in context menu on right-click', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
expect(screen.getByText('Customize icon & color…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows inline input when Rename section is clicked', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
expect(screen.getByRole('textbox', { name: 'Section name' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inline input is pre-filled with current label', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' }) as HTMLInputElement
|
||||
expect(input.value).toBe('Projects')
|
||||
})
|
||||
|
||||
it('calls onRenameSection with new name on Enter', () => {
|
||||
const onRenameSection = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' })
|
||||
fireEvent.change(input, { target: { value: 'My Projects' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onRenameSection).toHaveBeenCalledWith('Project', 'My Projects')
|
||||
})
|
||||
|
||||
it('cancels rename on Escape and hides input', () => {
|
||||
const onRenameSection = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(onRenameSection).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, PaintBrush,
|
||||
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ interface SidebarProps {
|
||||
onCustomizeType?: (typeName: string, icon: string, color: string) => void
|
||||
onUpdateTypeTemplate?: (typeName: string, template: string) => void
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
modifiedCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
@@ -48,7 +49,6 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
|
||||
{ label: 'Topics', type: 'Topic', Icon: Tag },
|
||||
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
||||
{ label: 'Themes', type: 'Theme', Icon: PaintBrush },
|
||||
]
|
||||
|
||||
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
|
||||
@@ -162,12 +162,13 @@ function applyCustomization(
|
||||
|
||||
function SortableSection({ group, sectionProps }: {
|
||||
group: SectionGroup
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void }
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
|
||||
}) {
|
||||
const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
|
||||
const isCollapsed = sectionProps.collapsed[group.type] ?? true
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px' }} {...attributes}>
|
||||
@@ -177,6 +178,10 @@ function SortableSection({ group, sectionProps }: {
|
||||
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
|
||||
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
|
||||
onToggle={() => sectionProps.onToggle(group.type)}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
onRenameSubmit={sectionProps.onRenameSubmit}
|
||||
onRenameCancel={sectionProps.onRenameCancel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -216,16 +221,21 @@ function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
|
||||
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize, onStartRename }: {
|
||||
pos: { x: number; y: number } | null; type: string | null
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onOpenCustomize: (type: string) => void
|
||||
onStartRename: (type: string) => void
|
||||
}) {
|
||||
if (!pos || !type) return null
|
||||
const btnClass = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left"
|
||||
return (
|
||||
<div ref={innerRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: pos.x, top: pos.y, minWidth: 180 }}>
|
||||
<button className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left" onClick={() => onOpenCustomize(type)}>
|
||||
Customize icon & color…
|
||||
<button className={btnClass} onClick={() => onStartRename(type)}>
|
||||
Rename section…
|
||||
</button>
|
||||
<button className={btnClass} onClick={() => onOpenCustomize(type)}>
|
||||
Customize icon & color…
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -258,11 +268,14 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
modifiedCount = 0, onCommitPush, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [renamingType, setRenamingType] = useState<string | null>(null)
|
||||
const [renameInitialValue, setRenameInitialValue] = useState('')
|
||||
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
|
||||
@@ -303,6 +316,20 @@ export const Sidebar = memo(function Sidebar({
|
||||
setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type)
|
||||
}, [])
|
||||
|
||||
const cancelRename = useCallback(() => setRenamingType(null), [])
|
||||
|
||||
const handleStartRename = useCallback((type: string) => {
|
||||
closeContextMenu()
|
||||
const group = allSectionGroups.find((g) => g.type === type)
|
||||
setRenameInitialValue(group?.label ?? type)
|
||||
setRenamingType(type)
|
||||
}, [closeContextMenu, allSectionGroups])
|
||||
|
||||
const handleRenameSubmit = useCallback((value: string) => {
|
||||
if (renamingType) onRenameSection?.(renamingType, value)
|
||||
setRenamingType(null)
|
||||
}, [renamingType, onRenameSection])
|
||||
|
||||
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
|
||||
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
@@ -314,6 +341,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -354,7 +382,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
</nav>
|
||||
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentType } from 'react'
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
@@ -75,6 +75,10 @@ export interface SectionContentProps {
|
||||
onCreateNewType?: () => void
|
||||
onContextMenu: (e: React.MouseEvent, type: string) => void
|
||||
onToggle: () => void
|
||||
isRenaming?: boolean
|
||||
renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void
|
||||
onRenameCancel?: () => void
|
||||
}
|
||||
|
||||
function childSelection(type: string, entry: VaultEntry): SidebarSelection {
|
||||
@@ -90,6 +94,7 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi
|
||||
export function SectionContent({
|
||||
group, items, isCollapsed, selection, onSelect, onSelectNote,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle,
|
||||
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
@@ -108,6 +113,10 @@ export function SectionContent({
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
onToggle={onToggle}
|
||||
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<SectionChildList
|
||||
@@ -143,25 +152,67 @@ function SectionChildList({ items, type, selection, sectionColor, sectionLightCo
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate }: {
|
||||
function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
|
||||
initialValue: string
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onSubmit(value.trim()) }
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel() }
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => onSubmit(value.trim())}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Section name"
|
||||
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
|
||||
style={{ padding: '1px 4px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
|
||||
isRenaming?: boolean; renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
if (isCollapsed) { onToggle(); onSelect() }
|
||||
else if (isActive) { onToggle() }
|
||||
else { onSelect() }
|
||||
}} onContextMenu={onContextMenu}
|
||||
}} onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
>
|
||||
<div className="flex items-center" style={{ gap: 4 }}>
|
||||
<Icon size={16} style={{ color: sectionColor }} />
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
{isRenaming && onRenameSubmit && onRenameCancel ? (
|
||||
<InlineRenameInput
|
||||
key={`rename-${type}`}
|
||||
initialValue={renameInitialValue ?? label}
|
||||
onSubmit={onRenameSubmit}
|
||||
onCancel={onRenameCancel}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 2 }}>
|
||||
{showCreate && (
|
||||
|
||||
@@ -23,13 +23,14 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
}
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: {
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onChange?: () => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
editable?: boolean
|
||||
}) {
|
||||
const navigateRef = useRef(onNavigateWikilink)
|
||||
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
|
||||
@@ -103,6 +104,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
editor={editor}
|
||||
theme={isDarkTheme ? 'dark' : 'light'}
|
||||
onChange={onChange}
|
||||
editable={editable}
|
||||
>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="[["
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
|
||||
import { type SortOption, type SortDirection, DEFAULT_DIRECTIONS, SORT_OPTIONS } from '../utils/noteListHelpers'
|
||||
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers'
|
||||
|
||||
export function SortDropdown({ groupLabel, current, direction, onChange }: {
|
||||
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
|
||||
groupLabel: string
|
||||
current: SortOption
|
||||
direction: SortDirection
|
||||
customProperties?: string[]
|
||||
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -27,58 +28,81 @@ export function SortDropdown({ groupLabel, current, direction, onChange }: {
|
||||
}
|
||||
|
||||
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
|
||||
const hasCustom = customProperties && customProperties.length > 0
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
|
||||
<button
|
||||
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
||||
title={`Sort by ${current}`}
|
||||
title={`Sort by ${getSortOptionLabel(current)}`}
|
||||
data-testid={`sort-button-${groupLabel}`}
|
||||
>
|
||||
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
|
||||
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
|
||||
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 150, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
|
||||
{SORT_OPTIONS.map((opt) => {
|
||||
const isActive = opt.value === current
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
|
||||
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
|
||||
data-testid={`sort-option-${opt.value}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, isActive ? direction : DEFAULT_DIRECTIONS[opt.value]) }}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-1.5 text-inherit">
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 ml-1">
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'asc') }}
|
||||
data-testid={`sort-dir-asc-${opt.value}`}
|
||||
title="Ascending"
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'desc') }}
|
||||
data-testid={`sort-dir-desc-${opt.value}`}
|
||||
title="Descending"
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
|
||||
style={{ width: 170, padding: 4, maxHeight: 280, overflowY: 'auto' }}
|
||||
data-testid={`sort-menu-${groupLabel}`}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SortRow key={opt.value} value={opt.value} label={opt.label} current={current} direction={direction} onSelect={handleSelect} />
|
||||
))}
|
||||
{hasCustom && (
|
||||
<>
|
||||
<div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />
|
||||
{customProperties.map((key) => {
|
||||
const value: SortOption = `property:${key}`
|
||||
return <SortRow key={value} value={value} label={key} current={current} direction={direction} onSelect={handleSelect} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortRow({ value, label, current, direction, onSelect }: {
|
||||
value: SortOption
|
||||
label: string
|
||||
current: SortOption
|
||||
direction: SortDirection
|
||||
onSelect: (opt: SortOption, dir: SortDirection) => void
|
||||
}) {
|
||||
const isActive = value === current
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
|
||||
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
|
||||
data-testid={`sort-option-${value}`}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-1.5 text-inherit truncate">
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 ml-1 shrink-0">
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(value, 'asc') }}
|
||||
data-testid={`sort-dir-asc-${value}`}
|
||||
title="Ascending"
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(value, 'desc') }}
|
||||
data-testid={`sort-dir-desc-${value}`}
|
||||
title="Descending"
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,4 +212,59 @@ describe('StatusBar', () => {
|
||||
)
|
||||
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('vault removal', () => {
|
||||
it('shows remove button for each vault when onRemoveVault is provided and multiple vaults exist', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
expect(screen.getByTestId('vault-menu-remove-Main Vault')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vault-menu-remove-Work Vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show remove button when only one vault exists', () => {
|
||||
const singleVault: VaultOption[] = [{ label: 'Only Vault', path: '/only/vault' }]
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/only/vault" vaults={singleVault} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
expect(screen.queryByTestId('vault-menu-remove-Only Vault')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRemoveVault with vault path when remove button is clicked', () => {
|
||||
const onRemoveVault = vi.fn()
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={onRemoveVault} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByTestId('vault-menu-remove-Work Vault'))
|
||||
expect(onRemoveVault).toHaveBeenCalledWith('/Users/luca/Work')
|
||||
})
|
||||
|
||||
it('closes menu after removing a vault', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByTestId('vault-menu-remove-Work Vault'))
|
||||
expect(screen.queryByTestId('vault-menu-remove-Work Vault')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('remove button has "Remove from list" title for accessibility', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
expect(screen.getAllByTitle('Remove from list')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show remove button when onRemoveVault is not provided', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
expect(screen.queryByTestId('vault-menu-remove-Main Vault')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal } from 'lucide-react'
|
||||
import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X } from 'lucide-react'
|
||||
import type { LastCommitInfo, SyncStatus } from '../types'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
export interface VaultOption {
|
||||
label: string
|
||||
path: string
|
||||
available?: boolean
|
||||
}
|
||||
|
||||
interface StatusBarProps {
|
||||
@@ -27,27 +28,60 @@ interface StatusBarProps {
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
buildNumber?: string
|
||||
onRemoveVault?: (path: string) => void
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
|
||||
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
|
||||
if (isActive) return <Check size={12} />
|
||||
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
|
||||
return <span style={{ width: 12 }} />
|
||||
}
|
||||
|
||||
function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
|
||||
cursor: unavailable ? 'not-allowed' : 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, onSelect, onRemove, canRemove }: { vault: VaultOption; isActive: boolean; onSelect: () => void; onRemove?: () => void; canRemove?: boolean }) {
|
||||
const unavailable = vault.available === false
|
||||
const canHover = !isActive && !unavailable
|
||||
return (
|
||||
<div
|
||||
role="button" onClick={onSelect}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
|
||||
role="button"
|
||||
onClick={unavailable ? undefined : onSelect}
|
||||
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
onMouseEnter={canHover ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
>
|
||||
{isActive ? <Check size={12} /> : <span style={{ width: 12 }} />}
|
||||
{vault.label}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
{vault.label}
|
||||
</span>
|
||||
{canRemove && onRemove && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove() }}
|
||||
style={{ display: 'flex', alignItems: 'center', padding: 2, borderRadius: 3, cursor: 'pointer', opacity: 0.5 }}
|
||||
title="Remove from list"
|
||||
data-testid={`vault-menu-remove-${vault.label}`}
|
||||
onMouseEnter={e => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<X size={10} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConnectGitHub, hasGitHub }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onOpenLocalFolder?: () => void; onConnectGitHub?: () => void; hasGitHub?: boolean }) {
|
||||
function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConnectGitHub, hasGitHub, onRemoveVault }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onOpenLocalFolder?: () => void; onConnectGitHub?: () => void; hasGitHub?: boolean; onRemoveVault?: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const activeVault = vaults.find((v) => v.path === vaultPath)
|
||||
@@ -69,7 +103,7 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn
|
||||
</span>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
|
||||
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} />)}
|
||||
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} onRemove={() => { onRemoveVault?.(v.path); setOpen(false) }} canRemove={!!onRemoveVault && vaults.length > 1} />)}
|
||||
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
|
||||
{onOpenLocalFolder && (
|
||||
<div
|
||||
@@ -116,21 +150,21 @@ const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cu
|
||||
const SEP_STYLE = { color: 'var(--border)' } as const
|
||||
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
|
||||
|
||||
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
if (status === 'conflict') return 'Conflict'
|
||||
if (status === 'error') return 'Sync failed'
|
||||
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
|
||||
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
|
||||
|
||||
function formatElapsedSync(lastSyncTime: number | null): string {
|
||||
if (!lastSyncTime) return 'Not synced'
|
||||
const elapsed = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
if (elapsed < 60) return 'Synced just now'
|
||||
const mins = Math.floor(elapsed / 60)
|
||||
return `Synced ${mins}m ago`
|
||||
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago`
|
||||
}
|
||||
|
||||
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
|
||||
return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime)
|
||||
}
|
||||
|
||||
function syncIconColor(status: SyncStatus): string {
|
||||
if (status === 'conflict') return 'var(--accent-orange)'
|
||||
if (status === 'error') return 'var(--muted-foreground)'
|
||||
return 'var(--accent-green)'
|
||||
return SYNC_COLORS[status] ?? 'var(--accent-green)'
|
||||
}
|
||||
|
||||
function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
@@ -156,56 +190,70 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: StatusBarProps) {
|
||||
// Force re-render every 30s to keep relative time label fresh
|
||||
function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void }) {
|
||||
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
|
||||
const isSyncing = status === 'syncing'
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onTriggerSync}
|
||||
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={isSyncing ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConflictBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
|
||||
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber, onRemoveVault }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const syncLabel = formatSyncLabel(syncStatus, lastSyncTime)
|
||||
const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw
|
||||
|
||||
return (
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onTriggerSync}
|
||||
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={syncStatus === 'syncing' ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
|
||||
</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
{conflictCount > 0 && (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
|
||||
<AlertTriangle size={13} />{conflictCount} conflict{conflictCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{modifiedCount > 0 && (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClickPending}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
|
||||
</>
|
||||
)}
|
||||
<ConflictBadge count={conflictCount} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
|
||||
|
||||
31
src/components/TrashedNoteBanner.test.tsx
Normal file
31
src/components/TrashedNoteBanner.test.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TrashedNoteBanner } from './TrashedNoteBanner'
|
||||
|
||||
describe('TrashedNoteBanner', () => {
|
||||
it('renders the banner with trash message', () => {
|
||||
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
|
||||
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Restore and Delete permanently buttons', () => {
|
||||
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
|
||||
expect(screen.getByText('Restore')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete permanently')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRestore when Restore button is clicked', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<TrashedNoteBanner onRestore={onRestore} onDeletePermanently={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
|
||||
expect(onRestore).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onDeletePermanently when Delete permanently button is clicked', () => {
|
||||
const onDeletePermanently = vi.fn()
|
||||
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={onDeletePermanently} />)
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
|
||||
expect(onDeletePermanently).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
44
src/components/TrashedNoteBanner.tsx
Normal file
44
src/components/TrashedNoteBanner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { memo } from 'react'
|
||||
import { Trash, ArrowCounterClockwise } from '@phosphor-icons/react'
|
||||
|
||||
interface TrashedNoteBannerProps {
|
||||
onRestore: () => void
|
||||
onDeletePermanently: () => void
|
||||
}
|
||||
|
||||
export const TrashedNoteBanner = memo(function TrashedNoteBanner({
|
||||
onRestore,
|
||||
onDeletePermanently,
|
||||
}: TrashedNoteBannerProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-3"
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--destructive-muted, color-mix(in srgb, var(--destructive) 8%, var(--background)))',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
data-testid="trashed-note-banner"
|
||||
>
|
||||
<Trash size={14} style={{ color: 'var(--destructive)', flexShrink: 0 }} />
|
||||
<span className="text-muted-foreground" style={{ flex: 1 }}>This note is in the Trash</span>
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-primary hover:bg-accent"
|
||||
onClick={onRestore}
|
||||
data-testid="trashed-banner-restore"
|
||||
>
|
||||
<ArrowCounterClockwise size={12} />
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-destructive hover:bg-destructive/10"
|
||||
onClick={onDeletePermanently}
|
||||
data-testid="trashed-banner-delete"
|
||||
>
|
||||
<Trash size={12} />
|
||||
Delete permanently
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -33,7 +33,9 @@ interface AppCommandsConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleDiff?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
activeNoteModified: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -53,7 +55,14 @@ interface AppCommandsConfig {
|
||||
onCreateTheme?: () => void
|
||||
onOpenTheme?: (themeId: string) => void
|
||||
onOpenVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
onRemoveActiveVault?: () => void
|
||||
onRestoreGettingStarted?: () => void
|
||||
isGettingStartedHidden?: boolean
|
||||
vaultCount?: number
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -132,7 +141,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCommitPush: config.onCommitPush,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleDiff: config.onToggleDiff,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
activeNoteModified: config.activeNoteModified,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
@@ -150,7 +161,14 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCreateTheme: config.onCreateTheme,
|
||||
onOpenTheme: config.onOpenTheme,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onCreateType: config.onCreateType,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
isUpdating: config.isUpdating,
|
||||
onRemoveActiveVault: config.onRemoveActiveVault,
|
||||
onRestoreGettingStarted: config.onRestoreGettingStarted,
|
||||
isGettingStartedHidden: config.isGettingStartedHidden,
|
||||
vaultCount: config.vaultCount,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -36,6 +36,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
activeTabPath: null as string | null,
|
||||
entries: [] as VaultEntry[],
|
||||
modifiedCount: 0,
|
||||
activeNoteModified: false,
|
||||
onQuickOpen: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onCreateNoteOfType: vi.fn(),
|
||||
@@ -264,6 +265,82 @@ describe('useCommandRegistry', () => {
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has toggle-diff command in View group', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-diff')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
expect(cmd!.label).toBe('Toggle Diff Mode')
|
||||
})
|
||||
|
||||
it('disables toggle-diff when no note is open', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-diff')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('disables toggle-diff when note has no changes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: false })),
|
||||
)
|
||||
const cmd = result.current.find(c => c.id === 'toggle-diff')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables toggle-diff when note has uncommitted changes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true })),
|
||||
)
|
||||
const cmd = result.current.find(c => c.id === 'toggle-diff')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onToggleDiff when toggle-diff executes', () => {
|
||||
const onToggleDiff = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true, onToggleDiff })),
|
||||
)
|
||||
result.current.find(c => c.id === 'toggle-diff')!.execute()
|
||||
expect(onToggleDiff).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('has toggle-backlinks command in View group', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
expect(cmd!.label).toBe('Toggle Backlinks')
|
||||
})
|
||||
|
||||
it('disables toggle-backlinks when no note is open', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables toggle-backlinks when a note is open', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
|
||||
)
|
||||
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onToggleInspector when toggle-backlinks executes', () => {
|
||||
const onToggleInspector = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleInspector })),
|
||||
)
|
||||
result.current.find(c => c.id === 'toggle-backlinks')!.execute()
|
||||
expect(onToggleInspector).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('toggle-inspector label includes Properties', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-inspector')
|
||||
expect(cmd!.label).toBe('Toggle Properties Panel')
|
||||
})
|
||||
|
||||
it('has open-daily-note command with shortcut', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'open-daily-note')
|
||||
@@ -281,6 +358,76 @@ describe('useCommandRegistry', () => {
|
||||
expect(onOpenDailyNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('check-updates command', () => {
|
||||
it('has check-updates command in Settings group', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'check-updates')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe('Check for Updates')
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.keywords).toContain('update')
|
||||
expect(cmd!.keywords).toContain('version')
|
||||
})
|
||||
|
||||
it('is enabled when not updating', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ isUpdating: false })),
|
||||
)
|
||||
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when updating', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ isUpdating: true })),
|
||||
)
|
||||
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onCheckForUpdates when executed', () => {
|
||||
const onCheckForUpdates = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ onCheckForUpdates })),
|
||||
)
|
||||
result.current.find(c => c.id === 'check-updates')!.execute()
|
||||
expect(onCheckForUpdates).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create-type command', () => {
|
||||
it('has create-type command in Note group when onCreateType is provided', () => {
|
||||
const onCreateType = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
|
||||
const cmd = result.current.find(c => c.id === 'create-type')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe('New Type')
|
||||
expect(cmd!.group).toBe('Note')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when onCreateType is not provided', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'create-type')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onCreateType when executed', () => {
|
||||
const onCreateType = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
|
||||
result.current.find(c => c.id === 'create-type')!.execute()
|
||||
expect(onCreateType).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has relevant keywords for discoverability', () => {
|
||||
const onCreateType = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
|
||||
const cmd = result.current.find(c => c.id === 'create-type')
|
||||
expect(cmd!.keywords).toContain('new')
|
||||
expect(cmd!.keywords).toContain('create')
|
||||
expect(cmd!.keywords).toContain('type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type-aware commands', () => {
|
||||
it('generates "New [Type]" commands from vault entries', () => {
|
||||
const entries = [
|
||||
@@ -478,6 +625,93 @@ describe('useCommandRegistry', () => {
|
||||
expect(result.current.find(c => c.id === 'open-theme-default')!.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vault management commands', () => {
|
||||
it('has remove-vault command in Settings group', () => {
|
||||
const onRemoveActiveVault = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRemoveActiveVault, vaultCount: 2,
|
||||
})))
|
||||
const cmd = result.current.find(c => c.id === 'remove-vault')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe('Remove Vault from List')
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables remove-vault when only one vault remains', () => {
|
||||
const onRemoveActiveVault = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRemoveActiveVault, vaultCount: 1,
|
||||
})))
|
||||
expect(result.current.find(c => c.id === 'remove-vault')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('disables remove-vault when onRemoveActiveVault is not provided', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ vaultCount: 3 })))
|
||||
expect(result.current.find(c => c.id === 'remove-vault')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onRemoveActiveVault when remove-vault executes', () => {
|
||||
const onRemoveActiveVault = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRemoveActiveVault, vaultCount: 2,
|
||||
})))
|
||||
result.current.find(c => c.id === 'remove-vault')!.execute()
|
||||
expect(onRemoveActiveVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has restore-getting-started command in Settings group', () => {
|
||||
const onRestoreGettingStarted = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRestoreGettingStarted, isGettingStartedHidden: true,
|
||||
})))
|
||||
const cmd = result.current.find(c => c.id === 'restore-getting-started')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe('Restore Getting Started Vault')
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('disables restore-getting-started when vault is not hidden', () => {
|
||||
const onRestoreGettingStarted = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRestoreGettingStarted, isGettingStartedHidden: false,
|
||||
})))
|
||||
expect(result.current.find(c => c.id === 'restore-getting-started')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onRestoreGettingStarted when restore command executes', () => {
|
||||
const onRestoreGettingStarted = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRestoreGettingStarted, isGettingStartedHidden: true,
|
||||
})))
|
||||
result.current.find(c => c.id === 'restore-getting-started')!.execute()
|
||||
expect(onRestoreGettingStarted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('remove-vault has relevant keywords for discoverability', () => {
|
||||
const onRemoveActiveVault = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRemoveActiveVault, vaultCount: 2,
|
||||
})))
|
||||
const cmd = result.current.find(c => c.id === 'remove-vault')
|
||||
expect(cmd!.keywords).toContain('vault')
|
||||
expect(cmd!.keywords).toContain('remove')
|
||||
expect(cmd!.keywords).toContain('disconnect')
|
||||
})
|
||||
|
||||
it('restore-getting-started has relevant keywords for discoverability', () => {
|
||||
const onRestoreGettingStarted = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({
|
||||
onRestoreGettingStarted, isGettingStartedHidden: true,
|
||||
})))
|
||||
const cmd = result.current.find(c => c.id === 'restore-getting-started')
|
||||
expect(cmd!.keywords).toContain('vault')
|
||||
expect(cmd!.keywords).toContain('restore')
|
||||
expect(cmd!.keywords).toContain('demo')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluralizeType', () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ interface CommandRegistryConfig {
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
@@ -32,8 +33,12 @@ interface CommandRegistryConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleDiff?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
activeNoteModified: boolean
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -50,6 +55,10 @@ interface CommandRegistryConfig {
|
||||
onSwitchTheme?: (themeId: string) => void
|
||||
onCreateTheme?: () => void
|
||||
onOpenTheme?: (themeId: string) => void
|
||||
onRemoveActiveVault?: () => void
|
||||
onRestoreGettingStarted?: () => void
|
||||
isGettingStartedHidden?: boolean
|
||||
vaultCount?: number
|
||||
}
|
||||
|
||||
const PLURAL_OVERRIDES: Record<string, string> = {
|
||||
@@ -106,8 +115,10 @@ export function buildTypeCommands(
|
||||
|
||||
export function buildViewCommands(
|
||||
hasActiveNote: boolean,
|
||||
activeNoteModified: boolean,
|
||||
onSetViewMode: (mode: ViewMode) => void,
|
||||
onToggleInspector: () => void,
|
||||
onToggleDiff: (() => void) | undefined,
|
||||
onToggleRawEditor: (() => void) | undefined,
|
||||
onToggleAIChat: (() => void) | undefined,
|
||||
zoomLevel: number,
|
||||
@@ -119,9 +130,11 @@ export function buildViewCommands(
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
@@ -170,11 +183,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
|
||||
onCheckForUpdates, isUpdating,
|
||||
onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -202,6 +219,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
// Note actions (contextual)
|
||||
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
|
||||
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
|
||||
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
|
||||
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
|
||||
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
|
||||
@@ -221,7 +239,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
// View
|
||||
...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
|
||||
...buildViewCommands(hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
|
||||
|
||||
// Appearance
|
||||
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme),
|
||||
@@ -229,6 +247,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
// Settings
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
|
||||
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
|
||||
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
@@ -236,10 +257,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
onCheckForUpdates, isUpdating,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
|
||||
@@ -28,6 +28,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -238,4 +239,54 @@ describe('useEntryActions', () => {
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRenameSection', () => {
|
||||
it('writes sidebar label frontmatter and updates entry in memory', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('Recipe', 'Recipes')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
|
||||
})
|
||||
|
||||
it('trims whitespace before saving', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('Recipe', ' Dishes ')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Dishes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Dishes' })
|
||||
})
|
||||
|
||||
it('deletes sidebar label when label is empty', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('Recipe', '')
|
||||
})
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: null })
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('NonExistent', 'Label')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,5 +68,17 @@ export function useEntryActions({
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate }
|
||||
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
const trimmed = label.trim()
|
||||
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
|
||||
if (trimmed) {
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed)
|
||||
} else {
|
||||
await handleDeleteProperty(typeEntry.path, 'sidebar label')
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock('../mock-tauri', () => ({
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./useVaultSwitcher', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
@@ -35,7 +38,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('transitions to ready when vault exists', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return true
|
||||
return null
|
||||
})
|
||||
@@ -50,7 +53,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('shows welcome screen when vault does not exist', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -60,14 +63,14 @@ describe('useOnboarding', () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' })
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Getting Started' })
|
||||
})
|
||||
|
||||
it('shows vault-missing when previously dismissed and vault gone', async () => {
|
||||
localStorage.setItem('laputa_welcome_dismissed', '1')
|
||||
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -80,15 +83,15 @@ describe('useOnboarding', () => {
|
||||
expect(result.current.state).toEqual({
|
||||
status: 'vault-missing',
|
||||
vaultPath: '/vault/deleted',
|
||||
defaultPath: '/mock/Documents/Laputa',
|
||||
defaultPath: '/mock/Documents/Getting Started',
|
||||
})
|
||||
})
|
||||
|
||||
it('handleCreateVault creates vault and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Getting Started'
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -102,13 +105,13 @@ describe('useOnboarding', () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' })
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
|
||||
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('handleCreateVault sets error on failure', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
|
||||
return null
|
||||
@@ -130,7 +133,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -152,7 +155,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('handleOpenFolder does nothing when picker is cancelled', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
@@ -173,7 +176,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('handleDismiss marks dismissed and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@ function makeThemeEntry(path: string, title: string): VaultEntry {
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,4 +199,73 @@ describe('useUpdater', () => {
|
||||
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
|
||||
expect(mockDownload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('checkForUpdates (manual)', () => {
|
||||
it('returns up-to-date when no update is available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('up-to-date')
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('returns available and sets status when update exists', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '3.0.0',
|
||||
body: 'Major release',
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('available')
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '3.0.0',
|
||||
notes: 'Major release',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error on network failure', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockRejectedValue(new Error('No internet'))
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('error')
|
||||
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
|
||||
})
|
||||
|
||||
it('returns up-to-date when not in Tauri', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('up-to-date')
|
||||
expect(mockCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,10 @@ export type UpdateStatus =
|
||||
| { state: 'ready'; version: string }
|
||||
| { state: 'error' }
|
||||
|
||||
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
|
||||
|
||||
export interface UpdateActions {
|
||||
checkForUpdates: () => Promise<UpdateCheckResult>
|
||||
startDownload: () => void
|
||||
openReleaseNotes: () => void
|
||||
dismiss: () => void
|
||||
@@ -21,31 +24,32 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
|
||||
const updateRef = useRef<unknown>(null)
|
||||
|
||||
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
|
||||
if (!isTauri()) return 'up-to-date'
|
||||
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const update = await check()
|
||||
if (!update) return 'up-to-date'
|
||||
|
||||
updateRef.current = update
|
||||
setStatus({
|
||||
state: 'available',
|
||||
version: update.version,
|
||||
notes: update.body ?? undefined,
|
||||
})
|
||||
return 'available'
|
||||
} catch {
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
return 'error'
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const update = await check()
|
||||
if (!update) return // up to date
|
||||
|
||||
updateRef.current = update
|
||||
setStatus({
|
||||
state: 'available',
|
||||
version: update.version,
|
||||
notes: update.body ?? undefined,
|
||||
})
|
||||
} catch {
|
||||
// Network error or 404 — fail silently
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
}
|
||||
}
|
||||
|
||||
// Delay so the app can render first
|
||||
const timer = setTimeout(checkForUpdates, 3000)
|
||||
const timer = setTimeout(() => { checkForUpdates() }, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
}, [checkForUpdates])
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
const update = updateRef.current as {
|
||||
@@ -88,7 +92,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
setStatus({ state: 'idle' })
|
||||
}, [])
|
||||
|
||||
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
|
||||
return { status, actions: { checkForUpdates, startDownload, openReleaseNotes, dismiss } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,13 +97,14 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [allContent, setAllContent] = useState<Record<string, string>>({})
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
|
||||
const tracker = useNewNoteTracker()
|
||||
const pendingSave = usePendingSaveTracker()
|
||||
const unsaved = useUnsavedTracker()
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
||||
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll()
|
||||
setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
||||
loadVaultData(vaultPath)
|
||||
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
|
||||
.catch((err) => console.warn('Vault scan failed:', err))
|
||||
@@ -111,9 +112,12 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
const loadModifiedFiles = useCallback(async () => {
|
||||
try {
|
||||
setModifiedFilesError(null)
|
||||
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
|
||||
} catch (err) {
|
||||
const message = typeof err === 'string' ? err : 'Failed to load changes'
|
||||
console.warn('Failed to load modified files:', err)
|
||||
setModifiedFilesError(message)
|
||||
setModifiedFiles([])
|
||||
}
|
||||
}, [vaultPath])
|
||||
@@ -174,7 +178,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
)
|
||||
|
||||
return {
|
||||
entries, allContent, modifiedFiles,
|
||||
entries, allContent, modifiedFiles, modifiedFilesError,
|
||||
addEntry, updateEntry, removeEntry, replaceEntry, updateContent,
|
||||
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
|
||||
getNoteStatus, commitAndPush, reloadVault,
|
||||
|
||||
374
src/hooks/useVaultSwitcher.test.ts
Normal file
374
src/hooks/useVaultSwitcher.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useVaultSwitcher, DEFAULT_VAULTS } from './useVaultSwitcher'
|
||||
import type { PersistedVaultList } from './useVaultSwitcher'
|
||||
|
||||
let mockVaultListStore: PersistedVaultList = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
|
||||
const mockInvokeFn = vi.fn((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useVaultSwitcher', () => {
|
||||
const onSwitch = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
// Re-set default implementation after resetAllMocks
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with default vaults', () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
|
||||
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
|
||||
})
|
||||
|
||||
it('loads persisted vaults on mount', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'My Vault', path: '/Users/luca/Laputa' }],
|
||||
active_vault: '/Users/luca/Laputa',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.allVaults).toHaveLength(2) // default + persisted
|
||||
expect(result.current.allVaults[1].label).toBe('My Vault')
|
||||
expect(result.current.allVaults[1].path).toBe('/Users/luca/Laputa')
|
||||
expect(result.current.allVaults[1].available).toBe(true)
|
||||
expect(result.current.vaultPath).toBe('/Users/luca/Laputa')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('load_vault_list', {})
|
||||
})
|
||||
|
||||
it('marks unavailable vaults when check_vault_exists returns false', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'External', path: '/Volumes/USB/vault' }],
|
||||
active_vault: null,
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.allVaults[1].available).toBe(false)
|
||||
expect(result.current.allVaults[1].label).toBe('External')
|
||||
})
|
||||
|
||||
it('persists vault list when adding a vault via handleVaultCloned', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/cloned/vault', 'Cloned')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
|
||||
list: expect.objectContaining({
|
||||
vaults: expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Cloned', path: '/cloned/vault' }),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('persists active vault when switching', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.switchVault('/work/vault')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
|
||||
list: expect.objectContaining({
|
||||
active_vault: '/work/vault',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
expect(onSwitch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'load_vault_list') return Promise.reject(new Error('disk error'))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
// Should fall back to defaults
|
||||
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not duplicate vaults with same path', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/some/vault', 'First')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/some/vault', 'Duplicate')
|
||||
})
|
||||
|
||||
const extras = result.current.allVaults.filter(v => v.path === '/some/vault')
|
||||
expect(extras).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens local folder and persists', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/MyVault')
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleOpenLocalFolder()
|
||||
})
|
||||
|
||||
expect(result.current.allVaults.some(v => v.path === '/Users/luca/MyVault')).toBe(true)
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
|
||||
})
|
||||
|
||||
describe('removeVault', () => {
|
||||
it('removes an extra vault from the list', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.allVaults).toHaveLength(2) // default + Work
|
||||
|
||||
act(() => {
|
||||
result.current.removeVault('/work/vault')
|
||||
})
|
||||
|
||||
expect(result.current.allVaults.some(v => v.path === '/work/vault')).toBe(false)
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "Work" removed from list')
|
||||
})
|
||||
|
||||
it('hides a default vault instead of deleting it', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
const defaultPath = DEFAULT_VAULTS[0].path
|
||||
expect(result.current.allVaults.some(v => v.path === defaultPath)).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.removeVault(defaultPath)
|
||||
})
|
||||
|
||||
expect(result.current.allVaults.some(v => v.path === defaultPath)).toBe(false)
|
||||
expect(result.current.isGettingStartedHidden).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to another vault when removing the active vault', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe('/work/vault')
|
||||
|
||||
act(() => {
|
||||
result.current.removeVault('/work/vault')
|
||||
})
|
||||
|
||||
// Should switch to the default vault
|
||||
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
|
||||
})
|
||||
|
||||
it('shows toast when vault is removed', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Docs', path: '/docs/vault' }],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.removeVault('/docs/vault')
|
||||
})
|
||||
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "Docs" removed from list')
|
||||
})
|
||||
|
||||
it('persists hidden_defaults when removing a default vault', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
// Add another vault first so we're not removing the last one
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/other/vault', 'Other')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.removeVault(DEFAULT_VAULTS[0].path)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
|
||||
list: expect.objectContaining({
|
||||
hidden_defaults: [DEFAULT_VAULTS[0].path],
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreGettingStarted', () => {
|
||||
it('un-hides the Getting Started vault', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [DEFAULT_VAULTS[0].path],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.isGettingStartedHidden).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
})
|
||||
|
||||
expect(result.current.isGettingStartedHidden).toBe(false)
|
||||
expect(result.current.allVaults.some(v => v.path === DEFAULT_VAULTS[0].path)).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to the Getting Started vault after restoring', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [DEFAULT_VAULTS[0].path],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
|
||||
expect(onToast).toHaveBeenCalledWith('Getting Started vault restored')
|
||||
})
|
||||
|
||||
it('attempts to create vault on disk if it does not exist', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [DEFAULT_VAULTS[0].path],
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'create_getting_started_vault') return Promise.resolve(DEFAULT_VAULTS[0].path)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('check_vault_exists', { path: DEFAULT_VAULTS[0].path })
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', { targetPath: DEFAULT_VAULTS[0].path })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGettingStartedHidden', () => {
|
||||
it('is false by default', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
expect(result.current.isGettingStartedHidden).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when Getting Started path is in hidden_defaults', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [DEFAULT_VAULTS[0].path],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
expect(result.current.isGettingStartedHidden).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,36 +1,91 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
|
||||
export const DEFAULT_VAULTS: VaultOption[] = isTauri()
|
||||
? [
|
||||
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
{ label: 'Laputa', path: '/Users/luca/Laputa' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
]
|
||||
export type { PersistedVaultList } from '../utils/vaultListStore'
|
||||
|
||||
export const GETTING_STARTED_LABEL = 'Getting Started'
|
||||
|
||||
export const DEFAULT_VAULTS: VaultOption[] = [
|
||||
{ label: GETTING_STARTED_LABEL, path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
]
|
||||
|
||||
interface UseVaultSwitcherOptions {
|
||||
onSwitch: () => void
|
||||
onToast: (msg: string) => void
|
||||
}
|
||||
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */
|
||||
function labelFromPath(path: string): string {
|
||||
return path.split('/').pop() || 'Local Vault'
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
|
||||
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
|
||||
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
|
||||
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
|
||||
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const visibleDefaults = useMemo(
|
||||
() => DEFAULT_VAULTS.filter(v => !hiddenDefaults.includes(v.path)),
|
||||
[hiddenDefaults],
|
||||
)
|
||||
const allVaults = useMemo(
|
||||
() => [...visibleDefaults, ...extraVaults],
|
||||
[visibleDefaults, extraVaults],
|
||||
)
|
||||
|
||||
const isGettingStartedHidden = useMemo(
|
||||
() => hiddenDefaults.includes(DEFAULT_VAULTS[0].path),
|
||||
[hiddenDefaults],
|
||||
)
|
||||
|
||||
// Refs ensure stable callbacks that always invoke the latest closures,
|
||||
// breaking the circular dependency between useVaultSwitcher and downstream hooks.
|
||||
const onSwitchRef = useRef(onSwitch)
|
||||
const onToastRef = useRef(onToast)
|
||||
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
|
||||
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadVaultList()
|
||||
.then(({ vaults, activeVault, hiddenDefaults: hidden }) => {
|
||||
if (cancelled) return
|
||||
setExtraVaults(vaults)
|
||||
setHiddenDefaults(hidden)
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
.finally(() => {
|
||||
hasLoadedRef.current = true
|
||||
setLoaded(true)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoadedRef.current) return
|
||||
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
|
||||
console.warn('Failed to persist vault list:', err),
|
||||
)
|
||||
}, [extraVaults, vaultPath, hiddenDefaults])
|
||||
|
||||
const addVault = useCallback((path: string, label: string) => {
|
||||
setExtraVaults(prev => prev.some(v => v.path === path) ? prev : [...prev, { label, path }])
|
||||
setExtraVaults(prev => {
|
||||
const exists = prev.some(v => v.path === path)
|
||||
return exists ? prev : [...prev, { label, path, available: true }]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const switchVault = useCallback((path: string) => {
|
||||
@@ -38,25 +93,69 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
onSwitchRef.current()
|
||||
}, [])
|
||||
|
||||
const handleVaultCloned = useCallback((path: string, label: string) => {
|
||||
const addAndSwitch = useCallback((path: string, label: string) => {
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
onToastRef.current(`Vault "${label}" cloned and opened`)
|
||||
}, [addVault, switchVault])
|
||||
|
||||
const handleVaultCloned = useCallback((path: string, label: string) => {
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" cloned and opened`)
|
||||
}, [addAndSwitch])
|
||||
|
||||
const handleOpenLocalFolder = useCallback(async () => {
|
||||
try {
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
const label = path.split('/').pop() || 'Local Vault'
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
} catch (err) {
|
||||
console.error('Failed to open local folder:', err)
|
||||
onToastRef.current(`Failed to open folder: ${err}`)
|
||||
}
|
||||
}, [addVault, switchVault])
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
const label = labelFromPath(path)
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
}, [addAndSwitch])
|
||||
|
||||
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder }
|
||||
const removeVault = useCallback((path: string) => {
|
||||
const isDefault = DEFAULT_VAULTS.some(v => v.path === path)
|
||||
if (isDefault) {
|
||||
setHiddenDefaults(prev => prev.includes(path) ? prev : [...prev, path])
|
||||
} else {
|
||||
setExtraVaults(prev => prev.filter(v => v.path !== path))
|
||||
}
|
||||
|
||||
// If removing the active vault, switch to the first remaining vault
|
||||
setVaultPath(currentPath => {
|
||||
if (currentPath !== path) return currentPath
|
||||
const remaining = [
|
||||
...DEFAULT_VAULTS.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
|
||||
...extraVaults.filter(v => v.path !== path),
|
||||
]
|
||||
if (remaining.length > 0) {
|
||||
onSwitchRef.current()
|
||||
return remaining[0].path
|
||||
}
|
||||
return currentPath
|
||||
})
|
||||
|
||||
const vault = [...DEFAULT_VAULTS, ...extraVaults].find(v => v.path === path)
|
||||
onToastRef.current(`Vault "${vault?.label ?? labelFromPath(path)}" removed from list`)
|
||||
}, [extraVaults, hiddenDefaults])
|
||||
|
||||
const restoreGettingStarted = useCallback(async () => {
|
||||
const defaultPath = DEFAULT_VAULTS[0].path
|
||||
// Un-hide the Getting Started vault
|
||||
setHiddenDefaults(prev => prev.filter(p => p !== defaultPath))
|
||||
// Try to create the vault if it doesn't exist on disk
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: defaultPath })
|
||||
if (!exists) {
|
||||
await tauriCall<string>('create_getting_started_vault', { targetPath: defaultPath })
|
||||
}
|
||||
} catch {
|
||||
// In mock/test mode, creation may fail — that's fine
|
||||
}
|
||||
switchVault(defaultPath)
|
||||
onToastRef.current('Getting Started vault restored')
|
||||
}, [switchVault])
|
||||
|
||||
return {
|
||||
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
|
||||
removeVault, restoreGettingStarted, isGettingStartedHidden,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
|
||||
properties: { Priority: 'High', 'Due date': '2026-06-15' },
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/responsibility/grow-newsletter.md',
|
||||
@@ -74,6 +75,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
|
||||
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' },
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/responsibility/manage-sponsorships.md',
|
||||
@@ -104,6 +106,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/matteo-cellini'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/procedure/write-weekly-essays.md',
|
||||
@@ -134,6 +137,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/procedure/run-sponsorships.md',
|
||||
@@ -164,6 +168,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/manage-sponsorships'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/experiment/stock-screener.md',
|
||||
@@ -195,6 +200,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
|
||||
properties: { Priority: 'Low', 'Due date': '2026-03-01' },
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
|
||||
@@ -226,6 +232,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
|
||||
properties: { Priority: 'Medium', Rating: 4 },
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/budget-allocation.md',
|
||||
@@ -256,6 +263,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/matteo-cellini.md',
|
||||
@@ -285,6 +293,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/maria-bianchi.md',
|
||||
@@ -314,6 +323,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Company: 'TechStart', Role: 'Product Manager' },
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/marco-verdi.md',
|
||||
@@ -343,6 +353,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/elena-russo.md',
|
||||
@@ -372,6 +383,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md',
|
||||
@@ -402,6 +414,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic/software-development.md',
|
||||
@@ -432,6 +445,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic/trading.md',
|
||||
@@ -462,6 +476,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/on-writing-well.md',
|
||||
@@ -492,6 +507,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/engineering-leadership-101.md',
|
||||
@@ -523,6 +539,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay/ai-agents-primer.md',
|
||||
@@ -553,6 +570,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
properties: {},
|
||||
},
|
||||
// --- Type documents ---
|
||||
{
|
||||
@@ -581,6 +599,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/responsibility.md',
|
||||
@@ -608,6 +627,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/procedure.md',
|
||||
@@ -635,6 +655,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/experiment.md',
|
||||
@@ -662,6 +683,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/person.md',
|
||||
@@ -689,6 +711,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/event.md',
|
||||
@@ -716,6 +739,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/topic.md',
|
||||
@@ -743,6 +767,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/essay.md',
|
||||
@@ -770,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/note.md',
|
||||
@@ -797,6 +823,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
{
|
||||
@@ -825,6 +852,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/book.md',
|
||||
@@ -852,6 +880,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
{
|
||||
@@ -882,6 +911,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/book/designing-data-intensive-applications.md',
|
||||
@@ -911,6 +941,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
|
||||
},
|
||||
// --- Trashed entries ---
|
||||
{
|
||||
@@ -942,6 +973,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/deprecated-api-notes.md',
|
||||
@@ -971,6 +1003,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/experiment/failed-seo-experiment.md',
|
||||
@@ -1001,6 +1034,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
// --- Archived entries ---
|
||||
{
|
||||
@@ -1023,6 +1057,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
modifiedAt: now - 86400 * 120,
|
||||
createdAt: now - 86400 * 200,
|
||||
fileSize: 680,
|
||||
@@ -1053,6 +1088,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
modifiedAt: now - 86400 * 90,
|
||||
createdAt: now - 86400 * 150,
|
||||
fileSize: 520,
|
||||
@@ -1116,6 +1152,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
properties: {},
|
||||
})
|
||||
}
|
||||
return entries
|
||||
@@ -1149,6 +1186,7 @@ MOCK_ENTRIES.push(
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/theme/dark.md',
|
||||
@@ -1176,6 +1214,7 @@ MOCK_ENTRIES.push(
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/theme/minimal.md',
|
||||
@@ -1203,6 +1242,7 @@ MOCK_ENTRIES.push(
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -82,8 +82,15 @@ let mockSettings: Settings = {
|
||||
auto_pull_interval_minutes: 5,
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = null
|
||||
|
||||
let mockVaultSettings: VaultSettings = { theme: null }
|
||||
|
||||
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
}
|
||||
|
||||
const mockThemes: ThemeFile[] = [
|
||||
{
|
||||
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
|
||||
@@ -197,6 +204,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
}
|
||||
return null
|
||||
},
|
||||
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
|
||||
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
|
||||
rename_note: handleRenameNote,
|
||||
github_list_repos: () => [
|
||||
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
|
||||
@@ -212,6 +221,7 @@ 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,
|
||||
migrate_is_a_to_type: () => 0,
|
||||
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
@@ -245,12 +255,14 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
}))
|
||||
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
|
||||
},
|
||||
get_default_vault_path: () => '/Users/mock/Documents/Laputa',
|
||||
get_last_vault_path: () => mockLastVaultPath,
|
||||
set_last_vault_path: (args: { path: string }) => { mockLastVaultPath = args.path; return null },
|
||||
get_default_vault_path: () => '/Users/mock/Documents/Getting Started',
|
||||
check_vault_exists: (args: { path: string }) => {
|
||||
// In mock mode, the demo-vault-v2 path always "exists"
|
||||
return args.path.includes('demo-vault-v2')
|
||||
},
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
list_themes: (): ThemeFile[] => [...mockThemes],
|
||||
get_theme: (args: { themeId: string }): ThemeFile => {
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface VaultEntry {
|
||||
template: string | null
|
||||
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
|
||||
outgoingLinks: string[]
|
||||
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
|
||||
properties: Record<string, string | number | boolean | null>
|
||||
}
|
||||
|
||||
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate } from './noteListHelpers'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection } from './noteListHelpers'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
@@ -154,3 +154,284 @@ describe('relativeDate', () => {
|
||||
expect(relativeDate(1700000000)).toMatch(/Nov 14/)
|
||||
})
|
||||
})
|
||||
|
||||
// --- buildRelationshipGroups tests ---
|
||||
|
||||
function makeVault(overrides: Partial<VaultEntry>[]): VaultEntry[] {
|
||||
return overrides.map((o, i) => makeEntry({
|
||||
path: `/Laputa/note/entry-${i}.md`,
|
||||
filename: `entry-${i}.md`,
|
||||
title: `Entry ${i}`,
|
||||
modifiedAt: 1700000000 - i * 100,
|
||||
...o,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('buildRelationshipGroups', () => {
|
||||
it('shows direct relationship properties from entity.relationships', () => {
|
||||
const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, building], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
|
||||
})
|
||||
|
||||
it('shows all direct relationships even when entries also appear as Children', () => {
|
||||
// The entity has "Notes" pointing at note1 and note2.
|
||||
// Those notes also have belongsTo pointing back at the entity.
|
||||
// Previously, Children consumed them via the seen set, suppressing "Notes".
|
||||
const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, note1, note2], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Notes')
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows all 5+ direct relationship properties', () => {
|
||||
const entries = makeVault([
|
||||
{ path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' },
|
||||
{ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' },
|
||||
{ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' },
|
||||
{ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' },
|
||||
{ path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' },
|
||||
{ path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' },
|
||||
])
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project',
|
||||
relationships: {
|
||||
'Belongs to': ['[[area/eng]]'],
|
||||
Notes: ['[[note/n1]]', '[[note/n2]]'],
|
||||
Owner: ['[[person/alice]]'],
|
||||
'Related to': ['[[project/sibling]]'],
|
||||
Topics: ['[[topic/rust]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, ...entries], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Belongs to')
|
||||
expect(labels).toContain('Notes')
|
||||
expect(labels).toContain('Owner')
|
||||
expect(labels).toContain('Related to')
|
||||
expect(labels).toContain('Topics')
|
||||
})
|
||||
|
||||
it('shows Children group for reverse belongsTo entries not covered by direct rels', () => {
|
||||
const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Children')
|
||||
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
|
||||
})
|
||||
|
||||
it('excludes Type key from relationship groups', () => {
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: { Type: ['[[type/project]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).not.toContain('Type')
|
||||
})
|
||||
|
||||
it('returns empty groups for entity with no relationships', () => {
|
||||
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
|
||||
const groups = buildRelationshipGroups(entity, [entity], {})
|
||||
expect(groups).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows single-item and multi-item relationship properties', () => {
|
||||
const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' })
|
||||
const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' })
|
||||
const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
|
||||
relationships: {
|
||||
Owner: ['[[person/alice]]'],
|
||||
Notes: ['[[note/n1]]', '[[note/n2]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2], {})
|
||||
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
|
||||
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows Instances group for Type entities', () => {
|
||||
const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 })
|
||||
const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 })
|
||||
const typeEntity = makeEntry({
|
||||
path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project',
|
||||
isA: 'Type', relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2], {})
|
||||
const labels = groups.map((g) => g.label)
|
||||
expect(labels).toContain('Instances')
|
||||
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('direct relationships are sorted alphabetically', () => {
|
||||
const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' })
|
||||
const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' })
|
||||
const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' })
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
|
||||
relationships: {
|
||||
Zebra: ['[[note/c]]'],
|
||||
Alpha: ['[[note/a]]'],
|
||||
Middle: ['[[note/b]]'],
|
||||
},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, a, b, c], {})
|
||||
const directLabels = groups.map((g) => g.label)
|
||||
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
|
||||
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
|
||||
})
|
||||
|
||||
it('Referenced By shows entries whose relatedTo matches the entity', () => {
|
||||
const referer = makeEntry({
|
||||
path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer',
|
||||
relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, referer], {})
|
||||
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
|
||||
})
|
||||
|
||||
it('Backlinks shows entries that mention the entity via wikilinks in content', () => {
|
||||
const linker = makeEntry({
|
||||
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
|
||||
relationships: {},
|
||||
})
|
||||
const allContent = { '/Laputa/note/linker.md': 'See [[Alpha]] for details.' }
|
||||
const groups = buildRelationshipGroups(entity, [entity, linker], allContent)
|
||||
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortComparator — custom properties', () => {
|
||||
it('sorts by string property alphabetically', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
|
||||
const b = makeEntry({ title: 'B', properties: { Priority: 'Low' } })
|
||||
const c = makeEntry({ title: 'C', properties: { Priority: 'Medium' } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Priority'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('sorts by numeric property', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
|
||||
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
|
||||
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Rating'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('sorts by date property chronologically', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { 'Due date': '2026-06-15' } })
|
||||
const b = makeEntry({ title: 'B', properties: { 'Due date': '2026-01-01' } })
|
||||
const c = makeEntry({ title: 'C', properties: { 'Due date': '2026-03-10' } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Due date'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
|
||||
})
|
||||
|
||||
it('pushes null values to end regardless of direction', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
|
||||
const b = makeEntry({ title: 'B', properties: {} })
|
||||
const c = makeEntry({ title: 'C', properties: { Priority: 'Low' } })
|
||||
const ascSorted = [a, b, c].sort(getSortComparator('property:Priority', 'asc'))
|
||||
expect(ascSorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
|
||||
const descSorted = [a, b, c].sort(getSortComparator('property:Priority', 'desc'))
|
||||
expect(descSorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('sorts descending when direction is desc', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
|
||||
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
|
||||
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
|
||||
const sorted = [a, b, c].sort(getSortComparator('property:Rating', 'desc'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['B', 'A', 'C'])
|
||||
})
|
||||
|
||||
it('handles entries with no properties field gracefully', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
|
||||
const b = makeEntry({ title: 'B', properties: {} })
|
||||
const sorted = [a, b].sort(getSortComparator('property:Priority'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('handles boolean property sorting', () => {
|
||||
const a = makeEntry({ title: 'A', properties: { Reviewed: true } })
|
||||
const b = makeEntry({ title: 'B', properties: { Reviewed: false } })
|
||||
const sorted = [a, b].sort(getSortComparator('property:Reviewed'))
|
||||
expect(sorted.map((e) => e.title)).toEqual(['B', 'A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSortableProperties', () => {
|
||||
it('returns union of all property keys across entries', () => {
|
||||
const entries = [
|
||||
makeEntry({ properties: { Priority: 'High', Rating: 5 } }),
|
||||
makeEntry({ properties: { Priority: 'Low', Company: 'Acme' } }),
|
||||
]
|
||||
expect(extractSortableProperties(entries)).toEqual(['Company', 'Priority', 'Rating'])
|
||||
})
|
||||
|
||||
it('returns empty array for entries without properties', () => {
|
||||
const entries = [makeEntry(), makeEntry()]
|
||||
expect(extractSortableProperties(entries)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty entry list', () => {
|
||||
expect(extractSortableProperties([])).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates property keys', () => {
|
||||
const entries = [
|
||||
makeEntry({ properties: { Priority: 'High' } }),
|
||||
makeEntry({ properties: { Priority: 'Low' } }),
|
||||
]
|
||||
expect(extractSortableProperties(entries)).toEqual(['Priority'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortOptionLabel', () => {
|
||||
it('returns label for built-in options', () => {
|
||||
expect(getSortOptionLabel('modified')).toBe('Modified')
|
||||
expect(getSortOptionLabel('title')).toBe('Title')
|
||||
})
|
||||
|
||||
it('returns property key for custom properties', () => {
|
||||
expect(getSortOptionLabel('property:Priority')).toBe('Priority')
|
||||
expect(getSortOptionLabel('property:Due date')).toBe('Due date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultDirection', () => {
|
||||
it('returns desc for time-based sorts', () => {
|
||||
expect(getDefaultDirection('modified')).toBe('desc')
|
||||
expect(getDefaultDirection('created')).toBe('desc')
|
||||
})
|
||||
|
||||
it('returns asc for other sorts', () => {
|
||||
expect(getDefaultDirection('title')).toBe('asc')
|
||||
expect(getDefaultDirection('status')).toBe('asc')
|
||||
expect(getDefaultDirection('property:Priority')).toBe('asc')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ export function sortByModified(a: VaultEntry, b: VaultEntry): number {
|
||||
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
||||
}
|
||||
|
||||
export type SortOption = 'modified' | 'created' | 'title' | 'status'
|
||||
export type SortOption = 'modified' | 'created' | 'title' | 'status' | `property:${string}`
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface SortConfig {
|
||||
@@ -93,11 +93,11 @@ export interface SortConfig {
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
export const DEFAULT_DIRECTIONS: Record<SortOption, SortDirection> = {
|
||||
modified: 'desc',
|
||||
created: 'desc',
|
||||
title: 'asc',
|
||||
status: 'asc',
|
||||
export const DEFAULT_SORT_OPTIONS: SortOption[] = ['modified', 'created', 'title', 'status']
|
||||
|
||||
export function getDefaultDirection(option: SortOption): SortDirection {
|
||||
if (option === 'modified' || option === 'created') return 'desc'
|
||||
return 'asc'
|
||||
}
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
@@ -107,31 +107,80 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
export function getSortOptionLabel(option: SortOption): string {
|
||||
if (option.startsWith('property:')) return option.slice('property:'.length)
|
||||
return SORT_OPTIONS.find((o) => o.value === option)?.label ?? option
|
||||
}
|
||||
|
||||
/** Extract sortable custom property keys from a list of entries. */
|
||||
export function extractSortableProperties(entries: VaultEntry[]): string[] {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
if (entry.properties) {
|
||||
for (const key of Object.keys(entry.properties)) keys.add(key)
|
||||
}
|
||||
}
|
||||
return [...keys].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
Active: 0, Paused: 1, Done: 2, Finished: 3,
|
||||
}
|
||||
|
||||
export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number {
|
||||
const dir = direction ?? DEFAULT_DIRECTIONS[option]
|
||||
const flip = dir === 'asc' ? 1 : -1
|
||||
switch (option) {
|
||||
case 'modified':
|
||||
return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0))
|
||||
case 'created':
|
||||
return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0))
|
||||
case 'title':
|
||||
return (a, b) => flip * a.title.localeCompare(b.title)
|
||||
case 'status':
|
||||
return (a, b) => {
|
||||
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
|
||||
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
|
||||
if (sa !== sb) return flip * (sa - sb)
|
||||
// Tiebreaker: always newest first regardless of direction
|
||||
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
||||
}
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}/
|
||||
|
||||
function tryParseDate(s: string): number | null {
|
||||
if (!ISO_DATE_RE.test(s)) return null
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime()) ? null : d.getTime()
|
||||
}
|
||||
|
||||
function compareNumericPair(a: unknown, b: unknown): number | null {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b
|
||||
if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0)
|
||||
return null
|
||||
}
|
||||
|
||||
function comparePropertyValues(a: unknown, b: unknown): number {
|
||||
const numeric = compareNumericPair(a, b)
|
||||
if (numeric !== null) return numeric
|
||||
const sa = String(a)
|
||||
const sb = String(b)
|
||||
const da = tryParseDate(sa)
|
||||
const db = tryParseDate(sb)
|
||||
if (da !== null && db !== null) return da - db
|
||||
return sa.localeCompare(sb)
|
||||
}
|
||||
|
||||
function makePropertyComparator(key: string, flip: number): (a: VaultEntry, b: VaultEntry) => number {
|
||||
return (a, b) => {
|
||||
const va = a.properties?.[key] ?? null
|
||||
const vb = b.properties?.[key] ?? null
|
||||
if (va == null && vb == null) return 0
|
||||
if (va == null) return 1
|
||||
if (vb == null) return -1
|
||||
return flip * comparePropertyValues(va, vb)
|
||||
}
|
||||
}
|
||||
|
||||
function makeBuiltinComparator(option: string, flip: number): (a: VaultEntry, b: VaultEntry) => number {
|
||||
if (option === 'title') return (a, b) => flip * a.title.localeCompare(b.title)
|
||||
if (option === 'created') return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0))
|
||||
if (option === 'status') return (a, b) => {
|
||||
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
|
||||
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
|
||||
if (sa !== sb) return flip * (sa - sb)
|
||||
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
||||
}
|
||||
return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0))
|
||||
}
|
||||
|
||||
export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number {
|
||||
const flip = (direction ?? getDefaultDirection(option)) === 'asc' ? 1 : -1
|
||||
if (option.startsWith('property:')) return makePropertyComparator(option.slice('property:'.length), flip)
|
||||
return makeBuiltinComparator(option, flip)
|
||||
}
|
||||
|
||||
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
|
||||
|
||||
export function loadSortPreferences(): Record<string, SortConfig> {
|
||||
@@ -144,7 +193,7 @@ export function loadSortPreferences(): Record<string, SortConfig> {
|
||||
if (typeof value === 'string') {
|
||||
// Migrate old format: bare SortOption string → SortConfig
|
||||
const opt = value as SortOption
|
||||
result[key] = { option: opt, direction: DEFAULT_DIRECTIONS[opt] }
|
||||
result[key] = { option: opt, direction: getDefaultDirection(opt) }
|
||||
} else {
|
||||
result[key] = value as SortConfig
|
||||
}
|
||||
@@ -220,17 +269,16 @@ export function buildRelationshipGroups(
|
||||
b.filterAndAdd('Instances', (e) => e.isA === entity.title)
|
||||
}
|
||||
|
||||
b.addFromRefs('Has', rels['Has'] ?? [])
|
||||
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
|
||||
b.addFromRefs('Topics', rels['Topics'] ?? [])
|
||||
|
||||
const handledKeys = new Set(['Has', 'Topics'])
|
||||
// Direct relationships first — all keys from entity.relationships take
|
||||
// priority so that reverse/computed groups (Children, Events, Referenced By)
|
||||
// only show *additional* entries not already covered by a direct property.
|
||||
Object.keys(rels)
|
||||
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
|
||||
.filter((k) => k.toLowerCase() !== 'type')
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
|
||||
|
||||
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
|
||||
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
||||
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))
|
||||
|
||||
|
||||
38
src/utils/vaultListStore.ts
Normal file
38
src/utils/vaultListStore.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
|
||||
export interface PersistedVaultList {
|
||||
vaults: Array<{ label: string; path: string }>
|
||||
active_vault: string | null
|
||||
hidden_defaults: string[]
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
async function checkAvailability(v: { label: string; path: string }): Promise<VaultOption> {
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: v.path })
|
||||
return { label: v.label, path: v.path, available: exists }
|
||||
} catch {
|
||||
return { label: v.label, path: v.path, available: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVault: string | null; hiddenDefaults: string[] }> {
|
||||
const data = await tauriCall<PersistedVaultList>('load_vault_list', {})
|
||||
const persisted = data?.vaults ?? []
|
||||
const checked = await Promise.all(persisted.map(checkAvailability))
|
||||
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
|
||||
}
|
||||
|
||||
export function saveVaultList(vaults: VaultOption[], activeVault: string, hiddenDefaults: string[] = []): Promise<void> {
|
||||
const list: PersistedVaultList = {
|
||||
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
|
||||
active_vault: activeVault,
|
||||
hidden_defaults: hiddenDefaults,
|
||||
}
|
||||
return tauriCall('save_vault_list', { list })
|
||||
}
|
||||
@@ -395,7 +395,7 @@ export default defineConfig({
|
||||
|
||||
// Tauri expects a fixed port
|
||||
server: {
|
||||
port: 5201,
|
||||
port: 5202,
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user