Compare commits

...

5 Commits

Author SHA1 Message Date
Test
e305c29e6e style: rustfmt vault_list.rs 2026-03-03 02:36:33 +01:00
Test
5b1804e5e1 feat: vault management — remove vault from list and restore Getting Started
Add ability to remove vaults from the app list without deleting files on disk,
and restore the bundled Getting Started demo vault when needed.

Changes:
- Rust: add hidden_defaults field to VaultList for tracking removed default vaults
- useVaultSwitcher: add removeVault() and restoreGettingStarted() with auto-switch
- useCommandRegistry: add 'Remove Vault from List' and 'Restore Getting Started Vault' commands
- StatusBar: add X button per vault item in vault menu dropdown
- 25 new tests covering removal, restore, edge cases, and command palette

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:36:33 +01:00
Luca Rossi
2c5cfe2923 feat: sort picker shows custom frontmatter properties (#185)
The sort dropdown now discovers all scalar properties (string, number,
boolean, date) across notes in the current list and shows them below a
separator after the built-in options. Properties that no longer exist
in the current list are gracefully handled by falling back to Modified.

Rust backend extracts custom properties during vault scan so they are
available on every VaultEntry without loading file content on demand.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:31:18 +01:00
Luca Rossi
35144aedfb feat: show archived note indicator banner in editor (#183)
Adds a subtle ArchivedNoteBanner component below the breadcrumb bar
when a note is archived. Banner includes:
- Muted gray background with archive icon + 'Archived' label
- 'Unarchive' button (ArrowUUpLeft icon) wired to same handler as Cmd+E
- Keyboard hint shown in button title

Editor remains fully editable (banner is purely informational).
Indicator appears/disappears reactively via entry.archived from store.

Co-authored-by: Test <test@test.com>
2026-03-03 02:31:10 +01:00
Luca Rossi
4d7252c78f feat: add command palette toggles for all BreadcrumbBar panels (#184)
Adds toggle commands to Cmd+K for:
- Toggle Properties Panel (prop/inspector)
- Toggle Diff Mode (diff) — disabled without note changes
- Toggle Backlinks (back) — disabled without note

Updates:
- useCommandRegistry.ts: 5 new commands with proper disabled states
- useAppCommands.ts: wires onToggleDiff and onToggleBacklinks
- Editor.tsx: added diffToggleRef prop (mirrors rawToggleRef pattern)
- App.tsx: creates diffToggleRef, passes to Editor, wires commands

10 new tests in useCommandRegistry.test.ts covering all new commands.

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 02:00:48 +01:00
34 changed files with 1521 additions and 118 deletions

View File

@@ -1 +1 @@
45402

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View 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": {}
}

View File

@@ -7,7 +7,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 3;
const CACHE_VERSION: u32 = 4;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {

View File

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

View File

@@ -12,6 +12,8 @@ pub struct VaultEntry {
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> {
@@ -79,6 +81,7 @@ mod tests {
},
],
active_vault: Some("/Users/luca/Laputa".to_string()),
hidden_defaults: vec![],
};
let loaded = save_and_reload(&list);
assert_eq!(loaded.vaults.len(), 2);
@@ -116,6 +119,7 @@ mod tests {
path: "/tmp/test".to_string(),
}],
active_vault: None,
hidden_defaults: vec![],
};
save_at(&path, &list).unwrap();
assert!(path.exists());
@@ -136,5 +140,31 @@ mod tests {
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());
}
}

View File

@@ -286,6 +286,8 @@ 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()
@@ -307,7 +309,9 @@ function App() {
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,
@@ -319,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,
@@ -343,6 +348,10 @@ function App() {
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 activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -432,6 +441,7 @@ function App() {
onSave={handleSave}
onTitleSync={handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}
@@ -442,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} />

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

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

View File

@@ -66,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()
@@ -91,6 +94,10 @@ function useEditorModeExclusion({
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
}, [rawToggleRef, handleToggleRawExclusive])
useEffect(() => {
if (diffToggleRef) diffToggleRef.current = handleToggleDiffExclusive
}, [diffToggleRef, handleToggleDiffExclusive])
return { handleToggleDiffExclusive, handleToggleRawExclusive }
}
@@ -116,6 +123,7 @@ export const Editor = memo(function Editor({
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme,
rawToggleRef,
diffToggleRef,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -152,7 +160,7 @@ 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

View File

@@ -3,6 +3,7 @@ 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'
@@ -173,6 +174,9 @@ export function EditorContent({
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{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>
)

View File

@@ -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,
})
@@ -1184,6 +1256,7 @@ const typeEntry: VaultEntry = {
order: null,
template: null,
outgoingLinks: [],
properties: {},
}
const entriesWithType = [...mockEntries, typeEntry]

View File

@@ -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,
@@ -67,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' }}>
@@ -75,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>
@@ -241,25 +242,31 @@ function isModifiedEntry(path: string, pathSet: Set<string>, suffixes: string[])
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) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))].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, modifiedSuffixes])
}, [filteredEntries, listSort, listDirection, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -272,7 +279,7 @@ 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 ---
@@ -315,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, modifiedSuffixes })
// 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,
@@ -395,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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'
@@ -28,6 +28,7 @@ interface StatusBarProps {
zoomLevel?: number
onZoomReset?: () => void
buildNumber?: string
onRemoveVault?: (path: string) => void
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
@@ -46,26 +47,41 @@ function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSPrope
}
}
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
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={unavailable ? undefined : onSelect}
style={vaultItemStyle(isActive, unavailable)}
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}`}
>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{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)
@@ -87,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
@@ -220,7 +236,7 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
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) {
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)
@@ -230,7 +246,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
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>

View File

@@ -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
@@ -57,6 +59,10 @@ interface AppCommandsConfig {
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. */
@@ -135,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,
@@ -157,6 +165,10 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleAIChat: config.onToggleAIChat,
onCheckForUpdates: config.onCheckForUpdates,
isUpdating: config.isUpdating,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,
vaultCount: config.vaultCount,
})
useKeyboardNavigation({

View File

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

View File

@@ -33,8 +33,10 @@ interface CommandRegistryConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
onToggleAIChat?: () => void
activeNoteModified: boolean
onCheckForUpdates?: () => void
isUpdating?: boolean
onZoomIn: () => void
@@ -53,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> = {
@@ -109,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,
@@ -122,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 },
@@ -173,13 +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
@@ -227,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),
@@ -235,6 +247,8 @@ 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]"
@@ -243,10 +257,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
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,

View File

@@ -28,6 +28,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
order: null,
template: null,
outgoingLinks: [],
properties: {},
...overrides,
})

View File

@@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
order: null,
template: null,
outgoingLinks: [],
properties: {},
...overrides,
})

View File

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

View File

@@ -58,6 +58,7 @@ function makeThemeEntry(path: string, title: string): VaultEntry {
sidebarLabel: null,
template: null,
outgoingLinks: [],
properties: {},
}
}

View File

@@ -3,7 +3,7 @@ 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 }
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 })
@@ -34,7 +34,7 @@ describe('useVaultSwitcher', () => {
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null }
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 })
@@ -187,4 +187,188 @@ describe('useVaultSwitcher', () => {
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)
})
})
})

View File

@@ -1,12 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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 type { PersistedVaultList } from '../utils/vaultListStore'
export const GETTING_STARTED_LABEL = 'Getting Started'
export const DEFAULT_VAULTS: VaultOption[] = [
{ label: 'Getting Started', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
{ label: GETTING_STARTED_LABEL, path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
interface UseVaultSwitcherOptions {
@@ -18,13 +22,31 @@ 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 [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
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],
)
const onSwitchRef = useRef(onSwitch)
const onToastRef = useRef(onToast)
@@ -35,9 +57,10 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
useEffect(() => {
let cancelled = false
loadVaultList()
.then(({ vaults, activeVault }) => {
.then(({ vaults, activeVault, hiddenDefaults: hidden }) => {
if (cancelled) return
setExtraVaults(vaults)
setHiddenDefaults(hidden)
if (activeVault) {
setVaultPath(activeVault)
onSwitchRef.current()
@@ -53,10 +76,10 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
useEffect(() => {
if (!hasLoadedRef.current) return
saveVaultList(extraVaults, vaultPath).catch(err =>
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, vaultPath])
}, [extraVaults, vaultPath, hiddenDefaults])
const addVault = useCallback((path: string, label: string) => {
setExtraVaults(prev => {
@@ -88,5 +111,51 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch])
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded }
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,
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups } from './noteListHelpers'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection } from './noteListHelpers'
import type { VaultEntry } from '../types'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
@@ -325,3 +325,113 @@ describe('buildRelationshipGroups', () => {
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')
})
})

View File

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

View File

@@ -5,6 +5,7 @@ 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> {
@@ -20,17 +21,18 @@ async function checkAvailability(v: { label: string; path: string }): Promise<Va
}
}
export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVault: string | null }> {
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 }
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
}
export function saveVaultList(vaults: VaultOption[], activeVault: string): Promise<void> {
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 })
}