Compare commits

...

2 Commits

Author SHA1 Message Date
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
12 changed files with 311 additions and 2 deletions

View File

@@ -1 +1 @@
21351
3852

View File

@@ -274,6 +274,16 @@ fn save_settings(settings: Settings) -> Result<(), String> {
settings::save_settings(settings)
}
#[tauri::command]
fn get_last_vault_path() -> Option<String> {
settings::get_last_vault()
}
#[tauri::command]
fn set_last_vault_path(path: String) -> Result<(), String> {
settings::set_last_vault(&path)
}
#[tauri::command]
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
@@ -539,6 +549,8 @@ pub fn run() {
get_settings,
update_menu_state,
save_settings,
get_last_vault_path,
set_last_vault_path,
github_list_repos,
github_create_repo,
clone_repo,

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

@@ -325,6 +325,7 @@ 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',

View File

@@ -53,6 +53,7 @@ interface AppCommandsConfig {
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
onOpenVault?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
isUpdating?: boolean
@@ -152,6 +153,7 @@ 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,

View File

@@ -316,6 +316,41 @@ describe('useCommandRegistry', () => {
})
})
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
@@ -178,6 +179,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onCheckForUpdates, isUpdating,
onCreateType,
} = config
const hasActiveNote = activeTabPath !== null
@@ -205,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) } },
@@ -241,7 +244,7 @@ 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,

View File

@@ -20,12 +20,17 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('./useVaultSwitcher', () => ({
persistLastVault: vi.fn(),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
import { useOnboarding } from './useOnboarding'
import { pickFolder } from '../utils/vault-dialog'
import { persistLastVault } from './useVaultSwitcher'
describe('useOnboarding', () => {
beforeEach(() => {
@@ -104,6 +109,7 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(persistLastVault).toHaveBeenCalledWith('/mock/Documents/Getting Started')
})
it('handleCreateVault sets error on failure', async () => {
@@ -148,6 +154,7 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/selected/folder' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(persistLastVault).toHaveBeenCalledWith('/selected/folder')
})
it('handleOpenFolder does nothing when picker is cancelled', async () => {

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { persistLastVault } from './useVaultSwitcher'
type OnboardingState =
| { status: 'loading' }
@@ -70,6 +71,7 @@ export function useOnboarding(initialVaultPath: string) {
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath: null })
markDismissed()
persistLastVault(vaultPath)
setState({ status: 'ready', vaultPath })
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
@@ -83,6 +85,7 @@ export function useOnboarding(initialVaultPath: string) {
const path = await pickFolder('Open vault folder')
if (!path) return
markDismissed()
persistLastVault(path)
setState({ status: 'ready', vaultPath: path })
} catch (err) {
setError(`Failed to open folder: ${err}`)

View File

@@ -0,0 +1,133 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockInvokeFn = vi.fn()
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...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
})
})
it('starts with the default vault path', () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
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
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(result.current.vaultPath).toBe('/Users/test/MyVault')
})
})
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
})
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 }))
act(() => {
result.current.switchVault('/Users/test/NewVault')
})
expect(result.current.vaultPath).toBe('/Users/test/NewVault')
expect(mockInvokeFn).toHaveBeenCalledWith('set_last_vault_path', { path: '/Users/test/NewVault' })
})
it('persists vault path on handleOpenLocalFolder', async () => {
vi.mocked(pickFolder).mockResolvedValue('/Users/test/OpenedVault')
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
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()
})
})

View File

@@ -1,4 +1,6 @@
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 type { VaultOption } from '../components/StatusBar'
@@ -11,12 +13,27 @@ 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)
}
export function persistLastVault(path: string): void {
tauriCall('set_last_vault_path', { path }).catch(() => {})
}
/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
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)
@@ -29,6 +46,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
const switchVault = useCallback((path: string) => {
setVaultPath(path)
persistLastVault(path)
onSwitchRef.current()
}, [])

View File

@@ -82,6 +82,8 @@ let mockSettings: Settings = {
auto_pull_interval_minutes: 5,
}
let mockLastVaultPath: string | null = null
let mockVaultSettings: VaultSettings = { theme: null }
const mockThemes: ThemeFile[] = [
@@ -245,6 +247,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}))
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
},
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"