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:
Test
2026-03-03 00:41:46 +01:00
parent 1a92b4694c
commit b489fa8e3e
7 changed files with 489 additions and 195 deletions

View File

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