Compare commits

...

12 Commits

Author SHA1 Message Date
Test
a33a000c57 fix: remove persistLastVault assertions from onboarding test 2026-03-03 00:59:09 +01:00
Test
f0b456bb8c fix: remove stale persistLastVault and unused imports after rebase 2026-03-03 00:57:47 +01:00
Test
b489fa8e3e 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>
2026-03-03 00:57:05 +01:00
Test
1a92b4694c fix: persist last vault path so app reopens correct vault after update
The vault path was stored only in React state (useState), which resets
on every app restart. Now the last active vault path is written to
~/Library/Application Support/com.laputa.app/last-vault.txt on every
vault switch, and loaded on startup. If the saved vault no longer exists,
the existing onboarding flow shows the vault picker instead of silently
falling back to the demo vault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:33:55 +01:00
Test
6b9ff9a4a2 feat: add "New Type" command to command palette (Cmd+K) 2026-03-03 00:31:10 +01:00
Luca Rossi
97d2182c0e test: add asserting wikilink navigation E2E test — screenshot.spec.ts test had no expect() calls (#180)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 23:55:57 +01:00
Test
80baa74175 feat: add 'Check for Updates' command to command palette
Users can now trigger an update check from Cmd+K → "Check for Updates"
without leaving the app. Shows toast for up-to-date/error states,
and the existing UpdateBanner for available updates. Command is
disabled while an update is downloading or ready to install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:53:37 +01:00
Test
f71e6d07e7 feat: rename demo vault from 'Demo v2' to 'Getting Started', remove 'Laputa' vault entry
- Rename 'Demo v2' → 'Getting Started' in vault switcher
- Remove 'Laputa' personal vault from default vault list
- Update default Getting Started vault path from Documents/Laputa to Documents/Getting Started
- Update mock handlers and tests to reflect new vault path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:49:36 +01:00
Test
b8a0702e3c fix: use compile-time env!() macro for BUILD_NUMBER instead of runtime std::env::var
The build number was always showing 'b0' because build.rs sets BUILD_NUMBER
via cargo:rustc-env (compile-time), but lib.rs was reading it with
std::env::var (runtime) which falls back to "0" when unset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:42:24 +01:00
Luca Rossi
c48f337c4d fix: bundle mcp-server into release app so AI Chat works (#178)
* feat: bundle mcp-server into release app so AI Chat works

- Add esbuild bundle script (scripts/bundle-mcp-server.mjs) that compiles
  mcp-server/index.js and ws-bridge.js into self-contained CJS bundles
- Output goes to src-tauri/resources/mcp-server/ (gitignored)
- Add Tauri resources config to copy bundles into Contents/Resources/mcp-server/
- Update mcp_server_dir() to look in Contents/Resources/ (not Contents/) in release
- Add bundle-mcp npm script; hook it into tauri beforeBuildCommand
- Exclude generated resources from ESLint

Previously AI Chat showed 'mcp-server not found at .../Contents/mcp-server'
because the release path lacked the 'Resources' segment and no files were bundled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: bundle mcp-server resources before Rust tests

* fix: add mcp-server as pnpm workspace package so esbuild can resolve its deps in CI

* fix: exclude src-tauri/target from eslint to fix CI lint failure

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 23:31:04 +01:00
Test
b32d46f482 style: fix rustfmt formatting in cache test assertions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:29:27 +01:00
Test
fd100e1c3b fix: sync changes badge count with list by invalidating stale vault cache
When .laputa-cache.json was committed to git, cloned vaults carried
absolute paths from the original machine. The badge (from get_modified_files)
used fresh local paths while the list filtered entries by stale cached paths,
causing a mismatch.

Three-layer fix:
- Invalidate cache when vault_path differs from current machine (CACHE_VERSION bump)
- Exclude .laputa-cache.json via .git/info/exclude to prevent future commits
- Defense-in-depth: match entries by relative path suffix in NoteList
- Surface error message when modified files fetch fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:29:27 +01:00
32 changed files with 1812 additions and 166 deletions

View File

@@ -1 +1 @@
21351

View File

@@ -60,6 +60,9 @@ jobs:
- name: Run frontend tests
run: pnpm test
- name: Bundle MCP server resources (required by Tauri build)
run: node scripts/bundle-mcp-server.mjs
- name: Run Rust tests
run: cargo test --manifest-path=src-tauri/Cargo.toml

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ final_selection.py
.claude-done
.claude-blocked
src-tauri/target
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
src-tauri/resources/

View File

@@ -231,3 +231,30 @@ test('full create note flow', async ({ page }) => {
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
})
// --- Flow 8: Wiki-link navigation ---
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
await page.waitForTimeout(300)
// Verify we opened the right note
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
// Click the wikilink — use mouse.click to fire real mousedown
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
await expect(wikilink).toBeVisible()
const box = await wikilink.boundingBox()
expect(box).not.toBeNull()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.waitForTimeout(300)
// New tab should open with the target note active
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
// Editor should show the target note's content
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
})

View File

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage']),
globalIgnores(['dist', 'coverage', 'src-tauri/resources/', 'src-tauri/target/']),
{
files: ['**/*.{ts,tsx}'],
extends: [

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
"lint": "eslint .",
"preview": "vite preview",
"tauri": "tauri",
@@ -64,6 +65,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.27.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

708
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,5 @@
packages:
- mcp-server
ignoredBuiltDependencies:
- esbuild

View File

@@ -0,0 +1,45 @@
/**
* Bundle the mcp-server Node.js files into self-contained CJS bundles
* that can be shipped as Tauri resources inside the .app bundle.
*
* Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js}
*/
import { build } from 'esbuild'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { mkdirSync, writeFileSync } from 'fs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
const SRC = join(ROOT, 'mcp-server')
const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server')
mkdirSync(OUT, { recursive: true })
// Tell Node.js that this directory contains CJS bundles, even if the
// root package.json declares "type": "module".
writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' }))
const shared = {
platform: 'node',
bundle: true,
format: 'cjs',
target: 'node18',
// Mark optional native bindings as external — ws works fine without them
external: ['bufferutil', 'utf-8-validate'],
logLevel: 'warning',
}
await build({
...shared,
entryPoints: [join(SRC, 'index.js')],
outfile: join(OUT, 'index.js'),
})
await build({
...shared,
entryPoints: [join(SRC, 'ws-bridge.js')],
outfile: join(OUT, 'ws-bridge.js'),
})
console.log('mcp-server bundled → src-tauri/resources/mcp-server/')

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
@@ -114,10 +116,7 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
#[tauri::command]
fn get_build_number() -> String {
{
let n = std::env::var("BUILD_NUMBER").unwrap_or_else(|_| "0".to_string());
format!("b{}", n)
}
format!("b{}", env!("BUILD_NUMBER"))
}
#[tauri::command]
@@ -277,6 +276,16 @@ fn save_settings(settings: Settings) -> Result<(), String> {
settings::save_settings(settings)
}
#[tauri::command]
fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
@@ -460,6 +469,22 @@ mod tests {
"expected 'b' prefix, got: {}",
result
);
assert_ne!(result, "b0", "build number should not fall back to 0");
}
}
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),
}
}
@@ -493,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![
@@ -541,6 +550,8 @@ pub fn run() {
get_settings,
update_menu_state,
save_settings,
load_vault_list,
save_vault_list,
github_list_repos,
github_create_repo,
clone_repo,

View File

@@ -27,10 +27,12 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
// On macOS the exe lives at Contents/MacOS/<binary>.
// Resources are placed at Contents/Resources/ by Tauri.
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("mcp-server"))
.map(|p| p.join("Resources").join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);

View File

@@ -71,6 +71,36 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
save_settings_at(&settings_path()?, settings)
}
fn last_vault_file() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
.ok_or_else(|| "Could not determine config directory".to_string())
}
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn set_last_vault_at(path: &PathBuf, vault_path: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
fs::write(path, vault_path.trim())
.map_err(|e| format!("Failed to write last vault path: {}", e))
}
pub fn get_last_vault() -> Option<String> {
last_vault_file().ok().and_then(|p| get_last_vault_at(&p))
}
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
set_last_vault_at(&last_vault_file()?, vault_path)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -199,4 +229,65 @@ mod tests {
assert!(result.is_ok());
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
}
#[test]
fn test_get_last_vault_returns_none_for_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
assert!(get_last_vault_at(&path).is_none());
}
#[test]
fn test_set_and_get_last_vault_roundtrip() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
set_last_vault_at(&path, "/Users/test/MyVault").unwrap();
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/MyVault")
);
}
#[test]
fn test_set_last_vault_trims_whitespace() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
set_last_vault_at(&path, " /Users/test/Vault ").unwrap();
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/Vault")
);
}
#[test]
fn test_get_last_vault_returns_none_for_empty_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
fs::write(&path, " \n ").unwrap();
assert!(get_last_vault_at(&path).is_none());
}
#[test]
fn test_set_last_vault_creates_parent_directories() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("dir").join("last-vault.txt");
set_last_vault_at(&path, "/Users/test/Vault").unwrap();
assert!(path.exists());
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/Vault")
);
}
#[test]
fn test_set_last_vault_overwrites_previous() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
set_last_vault_at(&path, "/Users/test/OldVault").unwrap();
set_last_vault_at(&path, "/Users/test/NewVault").unwrap();
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/NewVault")
);
}
}

View File

@@ -7,12 +7,16 @@ 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 = 2;
const CACHE_VERSION: u32 = 3;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
#[serde(default = "default_cache_version")]
version: u32,
/// The vault path when the cache was written. Used to detect stale caches
/// from a different machine or a moved vault directory.
#[serde(default)]
vault_path: String,
commit_hash: String,
entries: Vec<VaultEntry>,
}
@@ -107,6 +111,7 @@ fn write_cache(vault: &Path, cache: &VaultCache) {
if let Ok(data) = serde_json::to_string(cache) {
let _ = fs::write(cache_path(vault), data);
}
ensure_cache_excluded(vault);
}
/// Normalize an absolute path to a relative path for comparison with git output.
@@ -135,6 +140,23 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
.collect()
}
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
/// This prevents the cache (which contains machine-specific absolute paths)
/// from being committed and causing stale-path bugs on cloned vaults.
fn ensure_cache_excluded(vault: &Path) {
let exclude_path = vault.join(".git/info/exclude");
let entry = ".laputa-cache.json";
if let Ok(content) = fs::read_to_string(&exclude_path) {
if content.lines().any(|line| line.trim() == entry) {
return;
}
let separator = if content.ends_with('\n') { "" } else { "\n" };
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
let _ = fs::write(&exclude_path, format!("{entry}\n"));
}
}
/// Sort entries by modified_at descending and write the cache.
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
@@ -142,6 +164,7 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
vault,
&VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: hash,
entries: entries.clone(),
},
@@ -200,7 +223,10 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
};
if let Some(cache) = load_cache(vault_path) {
if cache.version != CACHE_VERSION {
let current_vault_str = vault_path.to_string_lossy();
let cache_stale = cache.version != CACHE_VERSION
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
if cache_stale {
let entries = scan_vault(vault_path)?;
return Ok(finalize_and_cache(vault_path, entries, current_hash));
}
@@ -288,6 +314,71 @@ mod tests {
assert_eq!(entries2[0].title, "Note");
}
#[test]
fn test_scan_vault_cached_invalidates_stale_vault_path() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
// Init git repo
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "note.md", "# Note\n\nContent.");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Build cache normally
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0]
.path
.starts_with(&vault.to_string_lossy().as_ref()),
"Entry path should start with vault path"
);
// Tamper with cache to simulate a clone from a different machine
let cache_file = cache_path(vault);
let cache_data = fs::read_to_string(&cache_file).unwrap();
let tampered = cache_data.replace(
&vault.to_string_lossy().as_ref(),
"/Users/other-machine/OtherVault",
);
fs::write(&cache_file, tampered).unwrap();
// Rescanning should invalidate the stale cache and produce correct paths
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert!(
entries2[0]
.path
.starts_with(&vault.to_string_lossy().as_ref()),
"After stale-cache invalidation, paths should use the current vault path, got: {}",
entries2[0].path
);
}
#[test]
fn test_scan_vault_cached_incremental_different_commit() {
let dir = TempDir::new().unwrap();

View File

@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
/// Default location for the Getting Started vault.
pub fn default_vault_path() -> Result<PathBuf, String> {
dirs::document_dir()
.map(|d| d.join("Laputa"))
.map(|d| d.join("Getting Started"))
.ok_or_else(|| "Could not determine Documents directory".to_string())
}
@@ -423,7 +423,7 @@ mod tests {
let path = default_vault_path().unwrap();
let path_str = path.to_string_lossy();
assert!(path_str.contains("Documents"));
assert!(path_str.ends_with("Laputa"));
assert!(path_str.ends_with("Getting Started"));
}
#[test]

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

View File

@@ -7,7 +7,7 @@
"frontendDist": "../dist",
"devUrl": "http://localhost:5202",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
},
"app": {
"withGlobalTauri": true,
@@ -37,6 +37,9 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"resources": {
"resources/mcp-server/**/*": "mcp-server/"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@@ -71,7 +71,7 @@ const mockCommandResults: Record<string, unknown> = {
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Laputa',
get_default_vault_path: '/Users/mock/Documents/Getting Started',
list_themes: [],
get_vault_settings: { theme: null },
}

View File

@@ -277,6 +277,18 @@ function App() {
const zoom = useZoom()
const buildNumber = useBuildNumber()
const { status: updateStatus, actions: updateActions } = useUpdater()
const handleCheckForUpdates = useCallback(async () => {
const result = await updateActions.checkForUpdates()
if (result === 'up-to-date') {
setToastMessage("You're on the latest version")
} else if (result === 'error') {
setToastMessage('Could not check for updates')
}
// 'available' → UpdateBanner handles it automatically
}, [updateActions, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
@@ -313,11 +325,12 @@ function App() {
if (entry) notes.handleSelectNote(entry)
},
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
})
const { status: updateStatus, actions: updateActions } = useUpdater()
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
// Show welcome/onboarding screen when vault doesn't exist
@@ -363,7 +376,7 @@ function App() {
{noteListVisible && (
<>
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>

View File

@@ -996,6 +996,33 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('matches entries by relative path suffix when absolute paths differ (cross-machine)', () => {
// Simulate a cloned vault where cached entries have paths from a different machine
const crossMachineEntries: VaultEntry[] = mockEntries.map((e) => ({
...e,
path: e.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'),
}))
const modifiedFromCurrentMachine = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
]
render(
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// Even though absolute paths differ, entries should match via relative path suffix
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows error message when modifiedFilesError is set', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
})
it('shows untracked (new) notes alongside modified notes in changes view', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },

View File

@@ -26,6 +26,7 @@ interface NoteListProps {
selectedNote: VaultEntry | null
allContent: Record<string, string>
modifiedFiles?: ModifiedFile[]
modifiedFilesError?: string | null
getNoteStatus?: (path: string) => NoteStatus
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
@@ -143,13 +144,13 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const emptyText = (isChangesView && changesError) ? `Failed to load changes: ${changesError}` : isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const hasHeader = isTrashView && expiredTrashCount > 0
if (searched.length === 0) {
@@ -232,10 +233,15 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
}
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet }: NoteListDataParams) {
function isModifiedEntry(path: string, pathSet: Set<string>, suffixes: string[]): boolean {
if (pathSet.has(path)) return true
return suffixes.some((suffix) => path.endsWith(suffix))
}
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'
@@ -248,12 +254,12 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const searched = useMemo(() => {
if (isEntityView) return []
if (isChangesView) {
const sorted = [...entries.filter((e) => modifiedPathSet.has(e.path))].sort(getSortComparator(listSort, listDirection))
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))
return filterByQuery(sorted, query)
}, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet])
}, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet, modifiedSuffixes])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -273,7 +279,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const defaultGetNoteStatus = (): NoteStatus => 'clean'
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -285,6 +291,14 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
[modifiedFiles],
)
// Suffix patterns for cross-machine robustness: if the vault cache carried
// stale absolute paths from another machine, fall back to matching by the
// relative path suffix so the changes view stays in sync with the badge.
const modifiedSuffixes = useMemo(
() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath),
[modifiedFiles],
)
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
() => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
[getNoteStatus, modifiedFiles, modifiedPathSet],
@@ -303,7 +317,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
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 })
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const noteListKeyboard = useNoteListKeyboard({
items: searched,
@@ -401,7 +415,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>

View File

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

View File

@@ -53,7 +53,10 @@ interface AppCommandsConfig {
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
onOpenVault?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
isUpdating?: boolean
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -150,7 +153,10 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCreateTheme: config.onCreateTheme,
onOpenTheme: config.onOpenTheme,
onOpenVault: config.onOpenVault,
onCreateType: config.onCreateType,
onToggleAIChat: config.onToggleAIChat,
onCheckForUpdates: config.onCheckForUpdates,
isUpdating: config.isUpdating,
})
useKeyboardNavigation({

View File

@@ -281,6 +281,76 @@ describe('useCommandRegistry', () => {
expect(onOpenDailyNote).toHaveBeenCalled()
})
describe('check-updates command', () => {
it('has check-updates command in Settings group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'check-updates')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Check for Updates')
expect(cmd!.group).toBe('Settings')
expect(cmd!.keywords).toContain('update')
expect(cmd!.keywords).toContain('version')
})
it('is enabled when not updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: false })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true)
})
it('is disabled when updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: true })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false)
})
it('calls onCheckForUpdates when executed', () => {
const onCheckForUpdates = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ onCheckForUpdates })),
)
result.current.find(c => c.id === 'check-updates')!.execute()
expect(onCheckForUpdates).toHaveBeenCalled()
})
})
describe('create-type command', () => {
it('has create-type command in Note group when onCreateType is provided', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('New Type')
expect(cmd!.group).toBe('Note')
expect(cmd!.enabled).toBe(true)
})
it('is disabled when onCreateType is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(false)
})
it('calls onCreateType when executed', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
result.current.find(c => c.id === 'create-type')!.execute()
expect(onCreateType).toHaveBeenCalled()
})
it('has relevant keywords for discoverability', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd!.keywords).toContain('new')
expect(cmd!.keywords).toContain('create')
expect(cmd!.keywords).toContain('type')
})
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [

View File

@@ -25,6 +25,7 @@ interface CommandRegistryConfig {
onSave: () => void
onOpenSettings: () => void
onOpenVault?: () => void
onCreateType?: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onArchiveNote: (path: string) => void
@@ -34,6 +35,8 @@ interface CommandRegistryConfig {
onToggleInspector: () => void
onToggleRawEditor?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
isUpdating?: boolean
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -175,6 +178,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onCheckForUpdates, isUpdating,
onCreateType,
} = config
const hasActiveNote = activeTabPath !== null
@@ -202,6 +207,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Note actions (contextual)
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
@@ -229,6 +235,7 @@ 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: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -237,9 +244,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates, isUpdating,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,

View File

@@ -20,6 +20,9 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('./useVaultSwitcher', () => ({
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
@@ -35,7 +38,7 @@ describe('useOnboarding', () => {
it('transitions to ready when vault exists', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return true
return null
})
@@ -50,7 +53,7 @@ describe('useOnboarding', () => {
it('shows welcome screen when vault does not exist', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -60,14 +63,14 @@ describe('useOnboarding', () => {
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' })
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Getting Started' })
})
it('shows vault-missing when previously dismissed and vault gone', async () => {
localStorage.setItem('laputa_welcome_dismissed', '1')
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -80,15 +83,15 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({
status: 'vault-missing',
vaultPath: '/vault/deleted',
defaultPath: '/mock/Documents/Laputa',
defaultPath: '/mock/Documents/Getting Started',
})
})
it('handleCreateVault creates vault and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa'
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Getting Started'
return null
})
@@ -102,13 +105,13 @@ describe('useOnboarding', () => {
await result.current.handleCreateVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' })
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
})
it('handleCreateVault sets error on failure', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
return null
@@ -130,7 +133,7 @@ describe('useOnboarding', () => {
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -152,7 +155,7 @@ describe('useOnboarding', () => {
it('handleOpenFolder does nothing when picker is cancelled', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -173,7 +176,7 @@ describe('useOnboarding', () => {
it('handleDismiss marks dismissed and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})

View File

@@ -199,4 +199,73 @@ describe('useUpdater', () => {
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
expect(mockDownload).toHaveBeenCalled()
})
describe('checkForUpdates (manual)', () => {
it('returns up-to-date when no update is available', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue(null)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(result.current.status).toEqual({ state: 'idle' })
})
it('returns available and sets status when update exists', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue({
version: '3.0.0',
body: 'Major release',
downloadAndInstall: vi.fn(),
})
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('available')
expect(result.current.status).toEqual({
state: 'available',
version: '3.0.0',
notes: 'Major release',
})
})
it('returns error on network failure', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockRejectedValue(new Error('No internet'))
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('error')
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
})
it('returns up-to-date when not in Tauri', async () => {
vi.mocked(isTauri).mockReturnValue(false)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(mockCheck).not.toHaveBeenCalled()
})
})
})

View File

@@ -11,7 +11,10 @@ export type UpdateStatus =
| { state: 'ready'; version: string }
| { state: 'error' }
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
export interface UpdateActions {
checkForUpdates: () => Promise<UpdateCheckResult>
startDownload: () => void
openReleaseNotes: () => void
dismiss: () => void
@@ -21,31 +24,32 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
const updateRef = useRef<unknown>(null)
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
if (!isTauri()) return 'up-to-date'
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (!update) return 'up-to-date'
updateRef.current = update
setStatus({
state: 'available',
version: update.version,
notes: update.body ?? undefined,
})
return 'available'
} catch {
console.warn('[updater] Failed to check for updates')
return 'error'
}
}, [])
useEffect(() => {
if (!isTauri()) return
const checkForUpdates = async () => {
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (!update) return // up to date
updateRef.current = update
setStatus({
state: 'available',
version: update.version,
notes: update.body ?? undefined,
})
} catch {
// Network error or 404 — fail silently
console.warn('[updater] Failed to check for updates')
}
}
// Delay so the app can render first
const timer = setTimeout(checkForUpdates, 3000)
const timer = setTimeout(() => { checkForUpdates() }, 3000)
return () => clearTimeout(timer)
}, [])
}, [checkForUpdates])
const startDownload = useCallback(async () => {
const update = updateRef.current as {
@@ -88,7 +92,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
setStatus({ state: 'idle' })
}, [])
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
return { status, actions: { checkForUpdates, startDownload, openReleaseNotes, dismiss } }
}
/**

View File

@@ -97,13 +97,14 @@ export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [allContent, setAllContent] = useState<Record<string, string>>({})
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
const tracker = useNewNoteTracker()
const pendingSave = usePendingSaveTracker()
const unsaved = useUnsavedTracker()
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll()
setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.catch((err) => console.warn('Vault scan failed:', err))
@@ -111,9 +112,12 @@ export function useVaultLoader(vaultPath: string) {
const loadModifiedFiles = useCallback(async () => {
try {
setModifiedFilesError(null)
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
} catch (err) {
const message = typeof err === 'string' ? err : 'Failed to load changes'
console.warn('Failed to load modified files:', err)
setModifiedFilesError(message)
setModifiedFiles([])
}
}, [vaultPath])
@@ -174,7 +178,7 @@ export function useVaultLoader(vaultPath: string) {
)
return {
entries, allContent, modifiedFiles,
entries, allContent, modifiedFiles, modifiedFilesError,
addEntry, updateEntry, removeEntry, replaceEntry, updateContent,
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
getNoteStatus, commitAndPush, reloadVault,

View File

@@ -0,0 +1,190 @@
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'
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: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
describe('useVaultSwitcher', () => {
const onSwitch = vi.fn()
const onToast = vi.fn()
beforeEach(() => {
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 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 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.loaded).toBe(true)
})
expect(result.current.allVaults[1].available).toBe(false)
expect(result.current.allVaults[1].label).toBe('External')
})
it('persists vault list when adding a vault via handleVaultCloned', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.handleVaultCloned('/cloned/vault', 'Cloned')
})
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 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.allVaults.some(v => v.path === '/Users/luca/MyVault')).toBe(true)
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
})

View File

@@ -1,36 +1,68 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isTauri } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
import type { VaultOption } from '../components/StatusBar'
export const DEFAULT_VAULTS: VaultOption[] = isTauri()
? [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
{ label: 'Laputa', path: '/Users/luca/Laputa' },
]
: [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
export type { PersistedVaultList } from '../utils/vaultListStore'
export const DEFAULT_VAULTS: VaultOption[] = [
{ label: 'Getting Started', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
interface UseVaultSwitcherOptions {
onSwitch: () => void
onToast: (msg: string) => void
}
/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */
function labelFromPath(path: string): string {
return path.split('/').pop() || 'Local Vault'
}
/** 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])
// 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) => {
@@ -38,25 +70,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 }
}

View File

@@ -82,8 +82,15 @@ let mockSettings: Settings = {
auto_pull_interval_minutes: 5,
}
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',
@@ -197,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' },
@@ -245,12 +254,14 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}))
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
},
get_default_vault_path: () => '/Users/mock/Documents/Laputa',
get_last_vault_path: () => mockLastVaultPath,
set_last_vault_path: (args: { path: string }) => { mockLastVaultPath = args.path; return null },
get_default_vault_path: () => '/Users/mock/Documents/Getting Started',
check_vault_exists: (args: { path: string }) => {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
list_themes: (): ThemeFile[] => [...mockThemes],
get_theme: (args: { themeId: string }): ThemeFile => {

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