diff --git a/design/vault-management-remove.pen b/design/vault-management-remove.pen new file mode 100644 index 00000000..4609e8d4 --- /dev/null +++ b/design/vault-management-remove.pen @@ -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": {} +} diff --git a/src-tauri/src/vault_list.rs b/src-tauri/src/vault_list.rs index 9f7581c4..e4e7841a 100644 --- a/src-tauri/src/vault_list.rs +++ b/src-tauri/src/vault_list.rs @@ -12,6 +12,8 @@ pub struct VaultEntry { pub struct VaultList { pub vaults: Vec, pub active_vault: Option, + #[serde(default)] + pub hidden_defaults: Vec, } fn vault_list_path() -> Result { @@ -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,35 @@ 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()); } } diff --git a/src/App.tsx b/src/App.tsx index 4953bea2..05ab33f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -348,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 @@ -448,7 +452,7 @@ function App() { - 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} /> + 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} /> setToastMessage(null)} /> diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index e2a4ce54..cfcaf736 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + 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( + + ) + fireEvent.click(screen.getByTitle('Switch vault')) + expect(screen.queryByTestId('vault-menu-remove-Main Vault')).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index dd706b4e..a498e088 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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 (
{ e.currentTarget.style.background = 'var(--hover)' } : undefined} onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined} data-testid={`vault-menu-item-${vault.label}`} > - - {vault.label} + + + {vault.label} + + {canRemove && onRemove && ( + { 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' }} + > + + + )}
) } -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(null) const activeVault = vaults.find((v) => v.path === vaultPath) @@ -87,7 +103,7 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn {open && (
- {vaults.map((v) => { onSwitchVault(v.path); setOpen(false) }} />)} + {vaults.map((v) => { onSwitchVault(v.path); setOpen(false) }} onRemove={() => { onRemoveVault?.(v.path); setOpen(false) }} canRemove={!!onRemoveVault && vaults.length > 1} />)}
{onOpenLocalFolder && (
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 (