From b489fa8e3ee6fe6bc311b96d64a31ca2b04d6549 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 3 Mar 2026 00:41:46 +0100 Subject: [PATCH] fix: persist vault list across app updates Vault list was stored only in React useState, lost on every app restart or update. Now persisted to ~/.config/com.laputa.app/vaults.json via Rust backend commands (load_vault_list, save_vault_list). - Add vault_list.rs module with VaultEntry/VaultList types and JSON I/O - Register load_vault_list/save_vault_list Tauri commands - Extract vaultListStore.ts utility for frontend persistence calls - Rewrite useVaultSwitcher to load on mount and persist on change - Show unavailable vaults greyed out with warning icon instead of silently removing them - Add mock handlers for browser/test environments - Add useVaultSwitcher tests covering persistence, availability, dedup Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/lib.rs | 47 +++--- src-tauri/src/vault_list.rs | 140 ++++++++++++++++++ src/components/StatusBar.tsx | 142 +++++++++++------- src/hooks/useVaultSwitcher.test.ts | 227 ++++++++++++++++++----------- src/hooks/useVaultSwitcher.ts | 85 +++++++---- src/mock-tauri/mock-handlers.ts | 7 + src/utils/vaultListStore.ts | 36 +++++ 7 files changed, 489 insertions(+), 195 deletions(-) create mode 100644 src-tauri/src/vault_list.rs create mode 100644 src/utils/vaultListStore.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d951abb1..5731f6b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ pub mod search; pub mod settings; pub mod theme; pub mod vault; +pub mod vault_list; use std::borrow::Cow; use std::path::Path; @@ -24,6 +25,7 @@ use search::SearchResponse; use settings::Settings; use theme::{ThemeFile, VaultSettings}; use vault::{RenameResult, VaultEntry}; +use vault_list::VaultList; /// Expand a leading `~` or `~/` in a path string to the user's home directory. /// Returns the original string unchanged if it doesn't start with `~` or if the @@ -275,13 +277,13 @@ fn save_settings(settings: Settings) -> Result<(), String> { } #[tauri::command] -fn get_last_vault_path() -> Option { - settings::get_last_vault() +fn load_vault_list() -> Result { + vault_list::load_vault_list() } #[tauri::command] -fn set_last_vault_path(path: String) -> Result<(), String> { - settings::set_last_vault(&path) +fn save_vault_list(list: VaultList) -> Result<(), String> { + vault_list::save_vault_list(&list) } #[tauri::command] @@ -471,6 +473,21 @@ mod tests { } } +fn spawn_ws_bridge(app: &mut tauri::App) { + use tauri::Manager; + let vault_path = dirs::home_dir() + .map(|h| h.join("Laputa")) + .unwrap_or_default(); + let vp_str = vault_path.to_string_lossy().to_string(); + match mcp::spawn_ws_bridge(&vp_str) { + Ok(child) => { + let state: tauri::State<'_, WsBridgeChild> = app.state(); + *state.0.lock().unwrap() = Some(child); + } + Err(e) => log::warn!("Failed to start ws-bridge: {}", e), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -501,23 +518,7 @@ pub fn run() { } run_startup_tasks(); - - // Spawn the MCP WebSocket bridge for the default vault - { - use tauri::Manager; - let vault_path = dirs::home_dir() - .map(|h| h.join("Laputa")) - .unwrap_or_default(); - let vp_str = vault_path.to_string_lossy().to_string(); - match mcp::spawn_ws_bridge(&vp_str) { - Ok(child) => { - let state: tauri::State<'_, WsBridgeChild> = app.state(); - *state.0.lock().unwrap() = Some(child); - } - Err(e) => log::warn!("Failed to start ws-bridge: {}", e), - } - } - + spawn_ws_bridge(app); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -549,8 +550,8 @@ pub fn run() { get_settings, update_menu_state, save_settings, - get_last_vault_path, - set_last_vault_path, + load_vault_list, + save_vault_list, github_list_repos, github_create_repo, clone_repo, diff --git a/src-tauri/src/vault_list.rs b/src-tauri/src/vault_list.rs new file mode 100644 index 00000000..9f7581c4 --- /dev/null +++ b/src-tauri/src/vault_list.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VaultEntry { + pub label: String, + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VaultList { + pub vaults: Vec, + pub active_vault: Option, +} + +fn vault_list_path() -> Result { + dirs::config_dir() + .map(|d| d.join("com.laputa.app").join("vaults.json")) + .ok_or_else(|| "Could not determine config directory".to_string()) +} + +fn load_at(path: &PathBuf) -> Result { + if !path.exists() { + return Ok(VaultList::default()); + } + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?; + serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e)) +} + +fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create config directory: {}", e))?; + } + let json = serde_json::to_string_pretty(list) + .map_err(|e| format!("Failed to serialize vault list: {}", e))?; + fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e)) +} + +pub fn load_vault_list() -> Result { + load_at(&vault_list_path()?) +} + +pub fn save_vault_list(list: &VaultList) -> Result<(), String> { + save_at(&vault_list_path()?, list) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn save_and_reload(list: &VaultList) -> VaultList { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("vaults.json"); + save_at(&path, list).unwrap(); + load_at(&path).unwrap() + } + + #[test] + fn default_vault_list_is_empty() { + let vl = VaultList::default(); + assert!(vl.vaults.is_empty()); + assert!(vl.active_vault.is_none()); + } + + #[test] + fn roundtrip_preserves_data() { + let list = VaultList { + vaults: vec![ + VaultEntry { + label: "My Vault".to_string(), + path: "/Users/luca/Laputa".to_string(), + }, + VaultEntry { + label: "Work".to_string(), + path: "/Users/luca/Work".to_string(), + }, + ], + active_vault: Some("/Users/luca/Laputa".to_string()), + }; + let loaded = save_and_reload(&list); + assert_eq!(loaded.vaults.len(), 2); + assert_eq!(loaded.vaults[0].label, "My Vault"); + assert_eq!(loaded.vaults[0].path, "/Users/luca/Laputa"); + assert_eq!(loaded.vaults[1].label, "Work"); + assert_eq!(loaded.active_vault.as_deref(), Some("/Users/luca/Laputa")); + } + + #[test] + fn load_returns_default_for_missing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.json"); + let result = load_at(&path).unwrap(); + assert!(result.vaults.is_empty()); + assert!(result.active_vault.is_none()); + } + + #[test] + fn load_returns_error_for_malformed_json() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("bad.json"); + fs::write(&path, "not valid json{{{").unwrap(); + let err = load_at(&path).unwrap_err(); + assert!(err.contains("Failed to parse vault list")); + } + + #[test] + fn save_creates_parent_directories() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested").join("dir").join("vaults.json"); + let list = VaultList { + vaults: vec![VaultEntry { + label: "Test".to_string(), + path: "/tmp/test".to_string(), + }], + active_vault: None, + }; + save_at(&path, &list).unwrap(); + assert!(path.exists()); + let loaded = load_at(&path).unwrap(); + assert_eq!(loaded.vaults.len(), 1); + } + + #[test] + fn vault_list_path_returns_ok() { + let result = vault_list_path(); + assert!(result.is_ok()); + assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app")); + } + + #[test] + fn empty_vault_list_roundtrip() { + let list = VaultList::default(); + let loaded = save_and_reload(&list); + assert!(loaded.vaults.is_empty()); + assert!(loaded.active_vault.is_none()); + } +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 7f50b531..dd706b4e 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -6,6 +6,7 @@ import { openExternalUrl } from '../utils/url' export interface VaultOption { label: string path: string + available?: boolean } interface StatusBarProps { @@ -29,19 +30,36 @@ interface StatusBarProps { buildNumber?: string } +function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) { + if (isActive) return + if (unavailable) return + return +} + +function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSProperties { + return { + display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, + cursor: unavailable ? 'not-allowed' : 'pointer', + background: isActive ? 'var(--hover)' : 'transparent', + opacity: unavailable ? 0.45 : 1, + color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12, + } +} + function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) { + const unavailable = vault.available === false + const canHover = !isActive && !unavailable return (
{ if (!isActive) e.currentTarget.style.background = 'var(--hover)' }} - onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }} + role="button" + onClick={unavailable ? undefined : onSelect} + style={vaultItemStyle(isActive, unavailable)} + title={unavailable ? `Vault not found: ${vault.path}` : vault.path} + onMouseEnter={canHover ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined} + onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined} + data-testid={`vault-menu-item-${vault.label}`} > - {isActive ? : } + {vault.label}
) @@ -116,21 +134,21 @@ const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cu const SEP_STYLE = { color: 'var(--border)' } as const const SYNC_ICON_MAP: Record = { syncing: Loader2, conflict: AlertTriangle } -function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string { - if (status === 'syncing') return 'Syncing…' - if (status === 'conflict') return 'Conflict' - if (status === 'error') return 'Sync failed' +const SYNC_LABELS: Record = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' } +const SYNC_COLORS: Record = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' } + +function formatElapsedSync(lastSyncTime: number | null): string { if (!lastSyncTime) return 'Not synced' - const elapsed = Math.round((Date.now() - lastSyncTime) / 1000) - if (elapsed < 60) return 'Synced just now' - const mins = Math.floor(elapsed / 60) - return `Synced ${mins}m ago` + const secs = Math.round((Date.now() - lastSyncTime) / 1000) + return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago` +} + +function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string { + return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime) } function syncIconColor(status: SyncStatus): string { - if (status === 'conflict') return 'var(--accent-orange)' - if (status === 'error') return 'var(--muted-foreground)' - return 'var(--accent-green)' + return SYNC_COLORS[status] ?? 'var(--accent-green)' } function CommitBadge({ info }: { info: LastCommitInfo }) { @@ -156,17 +174,59 @@ function CommitBadge({ info }: { info: LastCommitInfo }) { ) } +function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void }) { + const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw + const isSyncing = status === 'syncing' + return ( + + {formatSyncLabel(status, lastSyncTime)} + + ) +} + +function ConflictBadge({ count }: { count: number }) { + if (count <= 0) return null + return ( + <> + | + + {count} conflict{count > 1 ? 's' : ''} + + + ) +} + +function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) { + if (count <= 0) return null + return ( + <> + | + { e.currentTarget.style.background = 'var(--hover)' }} + onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }} + data-testid="status-modified-count" + >{count} pending + + ) +} + export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: StatusBarProps) { - // Force re-render every 30s to keep relative time label fresh const [, setTick] = useState(0) useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 30_000) return () => clearInterval(id) }, []) - const syncLabel = formatSyncLabel(syncStatus, lastSyncTime) - const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw - return (