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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> {
|
||||
settings::get_last_vault()
|
||||
fn load_vault_list() -> Result<VaultList, String> {
|
||||
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,
|
||||
|
||||
140
src-tauri/src/vault_list.rs
Normal file
140
src-tauri/src/vault_list.rs
Normal file
@@ -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<VaultEntry>,
|
||||
pub active_vault: Option<String>,
|
||||
}
|
||||
|
||||
fn vault_list_path() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("vaults.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
|
||||
if !path.exists() {
|
||||
return Ok(VaultList::default());
|
||||
}
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e))
|
||||
}
|
||||
|
||||
fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(list)
|
||||
.map_err(|e| format!("Failed to serialize vault list: {}", e))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e))
|
||||
}
|
||||
|
||||
pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
load_at(&vault_list_path()?)
|
||||
}
|
||||
|
||||
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
|
||||
save_at(&vault_list_path()?, list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn save_and_reload(list: &VaultList) -> VaultList {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("vaults.json");
|
||||
save_at(&path, list).unwrap();
|
||||
load_at(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_vault_list_is_empty() {
|
||||
let vl = VaultList::default();
|
||||
assert!(vl.vaults.is_empty());
|
||||
assert!(vl.active_vault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_preserves_data() {
|
||||
let list = VaultList {
|
||||
vaults: vec![
|
||||
VaultEntry {
|
||||
label: "My Vault".to_string(),
|
||||
path: "/Users/luca/Laputa".to_string(),
|
||||
},
|
||||
VaultEntry {
|
||||
label: "Work".to_string(),
|
||||
path: "/Users/luca/Work".to_string(),
|
||||
},
|
||||
],
|
||||
active_vault: Some("/Users/luca/Laputa".to_string()),
|
||||
};
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 <Check size={12} />
|
||||
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
|
||||
return <span style={{ width: 12 }} />
|
||||
}
|
||||
|
||||
function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
|
||||
cursor: unavailable ? 'not-allowed' : 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
|
||||
const unavailable = vault.available === false
|
||||
const canHover = !isActive && !unavailable
|
||||
return (
|
||||
<div
|
||||
role="button" onClick={onSelect}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
|
||||
role="button"
|
||||
onClick={unavailable ? undefined : onSelect}
|
||||
style={vaultItemStyle(isActive, unavailable)}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
onMouseEnter={canHover ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
>
|
||||
{isActive ? <Check size={12} /> : <span style={{ width: 12 }} />}
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
{vault.label}
|
||||
</div>
|
||||
)
|
||||
@@ -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<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
|
||||
|
||||
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
if (status === 'conflict') return 'Conflict'
|
||||
if (status === 'error') return 'Sync failed'
|
||||
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
|
||||
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
|
||||
|
||||
function formatElapsedSync(lastSyncTime: number | null): string {
|
||||
if (!lastSyncTime) return 'Not synced'
|
||||
const elapsed = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
if (elapsed < 60) return 'Synced just now'
|
||||
const mins = Math.floor(elapsed / 60)
|
||||
return `Synced ${mins}m ago`
|
||||
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago`
|
||||
}
|
||||
|
||||
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
|
||||
return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime)
|
||||
}
|
||||
|
||||
function syncIconColor(status: SyncStatus): string {
|
||||
if (status === 'conflict') return 'var(--accent-orange)'
|
||||
if (status === 'error') return 'var(--muted-foreground)'
|
||||
return 'var(--accent-green)'
|
||||
return SYNC_COLORS[status] ?? 'var(--accent-green)'
|
||||
}
|
||||
|
||||
function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
@@ -156,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 (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onTriggerSync}
|
||||
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={isSyncing ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConflictBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
|
||||
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: 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 (
|
||||
<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 }}>
|
||||
@@ -174,38 +234,10 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onTriggerSync}
|
||||
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={syncStatus === 'syncing' ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
|
||||
</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
{conflictCount > 0 && (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
|
||||
<AlertTriangle size={13} />{conflictCount} conflict{conflictCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{modifiedCount > 0 && (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClickPending}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
|
||||
</>
|
||||
)}
|
||||
<ConflictBadge count={conflictCount} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
|
||||
|
||||
@@ -1,133 +1,190 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useVaultSwitcher, DEFAULT_VAULTS } from './useVaultSwitcher'
|
||||
import type { PersistedVaultList } from './useVaultSwitcher'
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
let mockVaultListStore: PersistedVaultList = { vaults: [], active_vault: null }
|
||||
|
||||
const mockInvokeFn = vi.fn((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useVaultSwitcher, DEFAULT_VAULTS, persistLastVault } from './useVaultSwitcher'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
|
||||
describe('useVaultSwitcher', () => {
|
||||
const onSwitch = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_last_vault_path') return null
|
||||
if (cmd === 'set_last_vault_path') return null
|
||||
return null
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null }
|
||||
// Re-set default implementation after resetAllMocks
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with the default vault path', () => {
|
||||
it('starts with default vaults', () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
|
||||
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
|
||||
})
|
||||
|
||||
it('loads last vault path from backend on mount', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_last_vault_path') return '/Users/test/MyVault'
|
||||
if (cmd === 'set_last_vault_path') return null
|
||||
return null
|
||||
it('loads persisted vaults on mount', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'My Vault', path: '/Users/luca/Laputa' }],
|
||||
active_vault: '/Users/luca/Laputa',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.allVaults).toHaveLength(2) // default + persisted
|
||||
expect(result.current.allVaults[1].label).toBe('My Vault')
|
||||
expect(result.current.allVaults[1].path).toBe('/Users/luca/Laputa')
|
||||
expect(result.current.allVaults[1].available).toBe(true)
|
||||
expect(result.current.vaultPath).toBe('/Users/luca/Laputa')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('load_vault_list', {})
|
||||
})
|
||||
|
||||
it('marks unavailable vaults when check_vault_exists returns false', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'External', path: '/Volumes/USB/vault' }],
|
||||
active_vault: null,
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.vaultPath).toBe('/Users/test/MyVault')
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.allVaults[1].available).toBe(false)
|
||||
expect(result.current.allVaults[1].label).toBe('External')
|
||||
})
|
||||
|
||||
it('falls back to default when no last vault is stored', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_last_vault_path') return null
|
||||
return null
|
||||
})
|
||||
|
||||
it('persists vault list when adding a vault via handleVaultCloned', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
// Wait for the async load to complete
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_last_vault_path', {})
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
|
||||
})
|
||||
|
||||
it('falls back to default when get_last_vault_path fails', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_last_vault_path') throw new Error('read failed')
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
// Wait for the async load to complete
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_last_vault_path', {})
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
|
||||
})
|
||||
|
||||
it('persists vault path when switching', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.switchVault('/Users/test/NewVault')
|
||||
result.current.handleVaultCloned('/cloned/vault', 'Cloned')
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe('/Users/test/NewVault')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/NewVault' })
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
|
||||
list: expect.objectContaining({
|
||||
vaults: expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Cloned', path: '/cloned/vault' }),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('persists vault path on handleOpenLocalFolder', async () => {
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/test/OpenedVault')
|
||||
it('persists active vault when switching', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.switchVault('/work/vault')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
|
||||
list: expect.objectContaining({
|
||||
active_vault: '/work/vault',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
expect(onSwitch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'load_vault_list') return Promise.reject(new Error('disk error'))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
// Should fall back to defaults
|
||||
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not duplicate vaults with same path', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/some/vault', 'First')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/some/vault', 'Duplicate')
|
||||
})
|
||||
|
||||
const extras = result.current.allVaults.filter(v => v.path === '/some/vault')
|
||||
expect(extras).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens local folder and persists', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/MyVault')
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleOpenLocalFolder()
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe('/Users/test/OpenedVault')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/OpenedVault' })
|
||||
})
|
||||
|
||||
it('persists vault path on handleVaultCloned', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/Users/test/ClonedVault', 'ClonedVault')
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe('/Users/test/ClonedVault')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/ClonedVault' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistLastVault', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('calls set_last_vault_path with the given path', () => {
|
||||
persistLastVault('/Users/test/Vault')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/Vault' })
|
||||
})
|
||||
|
||||
it('does not throw when the backend call fails', () => {
|
||||
mockInvokeFn.mockRejectedValue(new Error('write failed'))
|
||||
expect(() => persistLastVault('/Users/test/Vault')).not.toThrow()
|
||||
expect(result.current.allVaults.some(v => v.path === '/Users/luca/MyVault')).toBe(true)
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,11 @@ 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 DEFAULT_VAULTS: VaultOption[] = [
|
||||
{ label: 'Getting Started', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
]
|
||||
@@ -13,35 +16,55 @@ interface UseVaultSwitcherOptions {
|
||||
onToast: (msg: string) => void
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
function labelFromPath(path: string): string {
|
||||
return path.split('/').pop() || 'Local Vault'
|
||||
}
|
||||
|
||||
export function persistLastVault(path: string): void {
|
||||
tauriCall('set_last_vault_path', { path }).catch(() => {})
|
||||
}
|
||||
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */
|
||||
/** 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 [loaded, setLoaded] = useState(false)
|
||||
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
|
||||
|
||||
// On mount, load the last vault path from persistent storage
|
||||
useEffect(() => {
|
||||
tauriCall<string | null>('get_last_vault_path', {}).then((saved) => {
|
||||
if (saved) setVaultPath(saved)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Refs ensure stable callbacks that always invoke the latest closures,
|
||||
// breaking the circular dependency between useVaultSwitcher and downstream hooks.
|
||||
const onSwitchRef = useRef(onSwitch)
|
||||
const onToastRef = useRef(onToast)
|
||||
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
|
||||
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadVaultList()
|
||||
.then(({ vaults, activeVault }) => {
|
||||
if (cancelled) return
|
||||
setExtraVaults(vaults)
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
.finally(() => {
|
||||
hasLoadedRef.current = true
|
||||
setLoaded(true)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoadedRef.current) return
|
||||
saveVaultList(extraVaults, vaultPath).catch(err =>
|
||||
console.warn('Failed to persist vault list:', err),
|
||||
)
|
||||
}, [extraVaults, vaultPath])
|
||||
|
||||
const addVault = useCallback((path: string, label: string) => {
|
||||
setExtraVaults(prev => prev.some(v => v.path === path) ? prev : [...prev, { label, path }])
|
||||
setExtraVaults(prev => {
|
||||
const exists = prev.some(v => v.path === path)
|
||||
return exists ? prev : [...prev, { label, path, available: true }]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const switchVault = useCallback((path: string) => {
|
||||
@@ -50,25 +73,23 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
onSwitchRef.current()
|
||||
}, [])
|
||||
|
||||
const handleVaultCloned = useCallback((path: string, label: string) => {
|
||||
const addAndSwitch = useCallback((path: string, label: string) => {
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
onToastRef.current(`Vault "${label}" cloned and opened`)
|
||||
}, [addVault, switchVault])
|
||||
|
||||
const handleVaultCloned = useCallback((path: string, label: string) => {
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" cloned and opened`)
|
||||
}, [addAndSwitch])
|
||||
|
||||
const handleOpenLocalFolder = useCallback(async () => {
|
||||
try {
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
const label = path.split('/').pop() || 'Local Vault'
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
} catch (err) {
|
||||
console.error('Failed to open local folder:', err)
|
||||
onToastRef.current(`Failed to open folder: ${err}`)
|
||||
}
|
||||
}, [addVault, switchVault])
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
const label = labelFromPath(path)
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
}, [addAndSwitch])
|
||||
|
||||
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder }
|
||||
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded }
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ let mockLastVaultPath: string | null = null
|
||||
|
||||
let mockVaultSettings: VaultSettings = { theme: null }
|
||||
|
||||
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
}
|
||||
|
||||
const mockThemes: ThemeFile[] = [
|
||||
{
|
||||
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
|
||||
@@ -199,6 +204,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
}
|
||||
return null
|
||||
},
|
||||
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
|
||||
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
|
||||
rename_note: handleRenameNote,
|
||||
github_list_repos: () => [
|
||||
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
|
||||
|
||||
36
src/utils/vaultListStore.ts
Normal file
36
src/utils/vaultListStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
|
||||
export interface PersistedVaultList {
|
||||
vaults: Array<{ label: string; path: string }>
|
||||
active_vault: string | null
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
async function checkAvailability(v: { label: string; path: string }): Promise<VaultOption> {
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: v.path })
|
||||
return { label: v.label, path: v.path, available: exists }
|
||||
} catch {
|
||||
return { label: v.label, path: v.path, available: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVault: string | null }> {
|
||||
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 }
|
||||
}
|
||||
|
||||
export function saveVaultList(vaults: VaultOption[], activeVault: string): Promise<void> {
|
||||
const list: PersistedVaultList = {
|
||||
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
|
||||
active_vault: activeVault,
|
||||
}
|
||||
return tauriCall('save_vault_list', { list })
|
||||
}
|
||||
Reference in New Issue
Block a user