From 3c57b0ff1a63e9844cd9a486a0277865ffb5a828 Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 28 Feb 2026 12:55:35 +0100 Subject: [PATCH] feat: integrate welcome screen with tests and fix App tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WelcomeScreen component (14 tests): - welcome mode: title, buttons, hint, loading, error states - vault-missing mode: path badge, different button labels useOnboarding hook (10 tests): - vault exists → ready, vault missing → welcome - dismissed + missing → vault-missing - create vault, open folder, dismiss transitions - error handling, picker cancellation, command failure fallback App.test.tsx updated to mock new Tauri commands (check_vault_exists, get_default_vault_path). Co-Authored-By: Claude Opus 4.6 --- (HOME)/.tauri/laputa-v2.key | 1 + (HOME)/.tauri/laputa-v2.key.pub | 1 + src/App.test.tsx | 5 +- src/App.tsx | 47 +++++- src/components/WelcomeScreen.test.tsx | 110 ++++++++++++++ src/components/WelcomeScreen.tsx | 206 ++++++++++++++++++++++++++ src/hooks/useOnboarding.test.ts | 204 +++++++++++++++++++++++++ src/hooks/useOnboarding.ts | 98 ++++++++++++ src/mock-tauri/mock-handlers.ts | 6 + 9 files changed, 671 insertions(+), 7 deletions(-) create mode 100644 (HOME)/.tauri/laputa-v2.key create mode 100644 (HOME)/.tauri/laputa-v2.key.pub create mode 100644 src/components/WelcomeScreen.test.tsx create mode 100644 src/components/WelcomeScreen.tsx create mode 100644 src/hooks/useOnboarding.test.ts create mode 100644 src/hooks/useOnboarding.ts diff --git a/(HOME)/.tauri/laputa-v2.key b/(HOME)/.tauri/laputa-v2.key new file mode 100644 index 00000000..5162b5c5 --- /dev/null +++ b/(HOME)/.tauri/laputa-v2.key @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg== \ No newline at end of file diff --git a/(HOME)/.tauri/laputa-v2.key.pub b/(HOME)/.tauri/laputa-v2.key.pub new file mode 100644 index 00000000..3d7e6ee6 --- /dev/null +++ b/(HOME)/.tauri/laputa-v2.key.pub @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK \ No newline at end of file diff --git a/src/App.test.tsx b/src/App.test.tsx index 6b805d74..cf3a0d21 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -70,6 +70,8 @@ vi.mock('./mock-tauri', () => ({ if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null } if (cmd === 'git_pull') return { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] } if (cmd === 'save_settings') return null + if (cmd === 'check_vault_exists') return true + if (cmd === 'get_default_vault_path') return '/Users/mock/Documents/Laputa' return null }), addMockEntry: vi.fn(), @@ -120,8 +122,9 @@ describe('App', () => { beforeEach(() => { vi.clearAllMocks() - // Reset view mode between tests so sidebar starts visible + // Reset view mode and onboarding state between tests localStorage.removeItem('laputa-view-mode') + localStorage.removeItem('laputa_welcome_dismissed') }) it('renders the four-panel layout', async () => { diff --git a/src/App.tsx b/src/App.tsx index 5cd14a4a..33be115e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { CommitDialog } from './components/CommitDialog' import { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' import { GitHubVaultModal } from './components/GitHubVaultModal' +import { WelcomeScreen } from './components/WelcomeScreen' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' import { useNoteActions } from './hooks/useNoteActions' @@ -27,6 +28,7 @@ import { useUpdater } from './hooks/useUpdater' import { useNavigationHistory } from './hooks/useNavigationHistory' import { useAutoSync } from './hooks/useAutoSync' import { useZoom } from './hooks/useZoom' +import { useOnboarding } from './hooks/useOnboarding' import { UpdateBanner } from './components/UpdateBanner' import { setApiKey } from './utils/ai-chat' import { extractOutgoingLinks } from './utils/wikilinks' @@ -113,13 +115,17 @@ function App() { onToast: (msg) => setToastMessage(msg), }) - const vault = useVaultLoader(vaultSwitcher.vaultPath) + const onboarding = useOnboarding(vaultSwitcher.vaultPath) + + // When onboarding resolves to a different vault path, update the switcher + const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath + const vault = useVaultLoader(resolvedPath) const { settings, saveSettings } = useSettings() useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) const autoSync = useAutoSync({ - vaultPath: vaultSwitcher.vaultPath, + vaultPath: resolvedPath, intervalMinutes: settings.auto_pull_interval_minutes, onVaultUpdated: vault.reloadVault, onConflict: (files) => { @@ -237,8 +243,8 @@ function App() { const handleRenameTab = useCallback(async (path: string, newTitle: string) => { await savePendingForPath(path) - await notes.handleRenameNote(path, newTitle, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles) - }, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath]) + await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles) + }, [notes, resolvedPath, vault, savePendingForPath]) /** H1→title sync: update VaultEntry.title and tab entry in memory. */ const handleTitleSync = useCallback((path: string, newTitle: string) => { @@ -279,6 +285,35 @@ function App() { const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null + // Show welcome/onboarding screen when vault doesn't exist + if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') { + const defaultPath = onboarding.state.defaultPath + return ( +
+ +
+ ) + } + + // Show loading spinner while checking vault + if (onboarding.state.status === 'loading') { + return ( +
+
+ Loading… +
+
+ ) + } + return (
@@ -325,7 +360,7 @@ function App() { onAddProperty={notes.handleAddProperty} showAIChat={dialogs.showAIChat} onToggleAIChat={dialogs.toggleAIChat} - vaultPath={vaultSwitcher.vaultPath} + vaultPath={resolvedPath} onTrashNote={entryActions.handleTrashNote} onRestoreNote={entryActions.handleRestoreNote} onArchiveNote={entryActions.handleArchiveNote} @@ -344,7 +379,7 @@ function App() { setToastMessage(null)} /> - + diff --git a/src/components/WelcomeScreen.test.tsx b/src/components/WelcomeScreen.test.tsx new file mode 100644 index 00000000..a8a5c2e7 --- /dev/null +++ b/src/components/WelcomeScreen.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { WelcomeScreen } from './WelcomeScreen' + +const defaultProps = { + mode: 'welcome' as const, + defaultVaultPath: '~/Documents/Laputa', + onCreateVault: vi.fn(), + onOpenFolder: vi.fn(), + creating: false, + error: null, +} + +describe('WelcomeScreen', () => { + describe('welcome mode', () => { + it('renders welcome title and subtitle', () => { + render() + expect(screen.getByText('Welcome to Laputa')).toBeInTheDocument() + expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument() + }) + + it('shows create vault and open folder buttons', () => { + render() + expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Create Getting Started vault') + expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open an existing folder') + }) + + it('shows default vault path hint', () => { + render() + expect(screen.getByText(/will be created in/)).toBeInTheDocument() + expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument() + }) + + it('calls onCreateVault when create button is clicked', () => { + const onCreateVault = vi.fn() + render() + fireEvent.click(screen.getByTestId('welcome-create-vault')) + expect(onCreateVault).toHaveBeenCalledOnce() + }) + + it('calls onOpenFolder when open folder button is clicked', () => { + const onOpenFolder = vi.fn() + render() + fireEvent.click(screen.getByTestId('welcome-open-folder')) + expect(onOpenFolder).toHaveBeenCalledOnce() + }) + + it('disables buttons while creating', () => { + render() + expect(screen.getByTestId('welcome-create-vault')).toBeDisabled() + expect(screen.getByTestId('welcome-open-folder')).toBeDisabled() + }) + + it('shows loading text while creating', () => { + render() + expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Creating vault…') + }) + + it('shows error message when error is set', () => { + render() + expect(screen.getByTestId('welcome-error')).toHaveTextContent('Permission denied') + }) + + it('does not show error when error is null', () => { + render() + expect(screen.queryByTestId('welcome-error')).not.toBeInTheDocument() + }) + + it('does not show path badge in welcome mode', () => { + render() + expect(screen.queryByText('~/Laputa')).not.toBeInTheDocument() + }) + }) + + describe('vault-missing mode', () => { + const missingProps = { + ...defaultProps, + mode: 'vault-missing' as const, + missingPath: '~/Laputa', + } + + it('renders vault not found title', () => { + render() + expect(screen.getByText('Vault not found')).toBeInTheDocument() + expect(screen.getByText(/could not be found on disk/)).toBeInTheDocument() + }) + + it('shows the missing vault path in a badge', () => { + render() + expect(screen.getByText('~/Laputa')).toBeInTheDocument() + }) + + it('shows "Choose a different folder" instead of "Open an existing folder"', () => { + render() + expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Choose a different folder') + }) + + it('does not show vault path hint in vault-missing mode', () => { + render() + expect(screen.queryByText(/will be created in/)).not.toBeInTheDocument() + }) + }) + + describe('data-testid', () => { + it('has welcome-screen container testid', () => { + render() + expect(screen.getByTestId('welcome-screen')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx new file mode 100644 index 00000000..24c25e87 --- /dev/null +++ b/src/components/WelcomeScreen.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react' +import { FolderOpen, Plus, AlertTriangle, Loader2 } from 'lucide-react' + +interface WelcomeScreenProps { + mode: 'welcome' | 'vault-missing' + missingPath?: string + defaultVaultPath: string + onCreateVault: () => void + onOpenFolder: () => void + creating: boolean + error: string | null +} + +const CONTAINER_STYLE: React.CSSProperties = { + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'var(--sidebar)', +} + +const CARD_STYLE: React.CSSProperties = { + width: 520, + background: 'var(--background)', + borderRadius: 12, + border: '1px solid var(--border)', + padding: 48, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 24, +} + +const ICON_WRAP_STYLE: React.CSSProperties = { + width: 64, + height: 64, + borderRadius: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +} + +const TITLE_STYLE: React.CSSProperties = { + fontSize: 28, + fontWeight: 700, + letterSpacing: -0.5, + color: 'var(--foreground)', + textAlign: 'center', + margin: 0, +} + +const SUBTITLE_STYLE: React.CSSProperties = { + fontSize: 14, + lineHeight: 1.6, + color: 'var(--muted-foreground)', + textAlign: 'center', + margin: 0, +} + +const DIVIDER_STYLE: React.CSSProperties = { + width: '100%', + height: 1, + background: 'var(--border)', +} + +const PRIMARY_BTN_STYLE: React.CSSProperties = { + width: '100%', + height: 44, + borderRadius: 8, + border: 'none', + background: 'var(--primary)', + color: 'white', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, +} + +const SECONDARY_BTN_STYLE: React.CSSProperties = { + width: '100%', + height: 44, + borderRadius: 8, + border: '1px solid var(--border)', + background: 'var(--background)', + color: 'var(--foreground)', + fontSize: 14, + fontWeight: 500, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, +} + +const HINT_STYLE: React.CSSProperties = { + fontSize: 12, + color: 'var(--muted-foreground)', + textAlign: 'center', + margin: 0, +} + +const PATH_BADGE_STYLE: React.CSSProperties = { + width: '100%', + background: 'var(--sidebar)', + borderRadius: 6, + padding: '8px 12px', + textAlign: 'center', +} + +const ERROR_STYLE: React.CSSProperties = { + fontSize: 13, + color: 'var(--destructive, #e03e3e)', + textAlign: 'center', + margin: 0, +} + +export function WelcomeScreen({ mode, missingPath, defaultVaultPath, onCreateVault, onOpenFolder, creating, error }: WelcomeScreenProps) { + const [hoverPrimary, setHoverPrimary] = useState(false) + const [hoverSecondary, setHoverSecondary] = useState(false) + + const isWelcome = mode === 'welcome' + + return ( +
+
+
+ {isWelcome + ? + : + } +
+ +
+

+ {isWelcome ? 'Welcome to Laputa' : 'Vault not found'} +

+

+ {isWelcome + ? 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.' + : 'The vault folder could not be found on disk.\nIt may have been moved or deleted.' + } +

+
+ + {!isWelcome && missingPath && ( +
+ + {missingPath} + +
+ )} + +
+ +
+ + + +
+ + {error &&

{error}

} + + {isWelcome && !error && ( +

+ The Getting Started vault will be created in {defaultVaultPath} +

+ )} +
+
+ ) +} diff --git a/src/hooks/useOnboarding.test.ts b/src/hooks/useOnboarding.test.ts new file mode 100644 index 00000000..94e22282 --- /dev/null +++ b/src/hooks/useOnboarding.test.ts @@ -0,0 +1,204 @@ +import { renderHook, waitFor, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// localStorage mock +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +const mockInvokeFn = vi.fn() + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: (...args: unknown[]) => mockInvokeFn(...args), +})) + +vi.mock('../utils/vault-dialog', () => ({ + pickFolder: vi.fn(), +})) + +import { useOnboarding } from './useOnboarding' +import { pickFolder } from '../utils/vault-dialog' + +describe('useOnboarding', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + 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 === 'check_vault_exists') return true + return null + }) + + const { result } = renderHook(() => useOnboarding('/vault/path')) + + await waitFor(() => { + expect(result.current.state.status).toBe('ready') + }) + expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/path' }) + }) + + 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 === 'check_vault_exists') return false + return null + }) + + const { result } = renderHook(() => useOnboarding('/vault/missing')) + + await waitFor(() => { + expect(result.current.state.status).toBe('welcome') + }) + expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' }) + }) + + 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 === 'check_vault_exists') return false + return null + }) + + const { result } = renderHook(() => useOnboarding('/vault/deleted')) + + await waitFor(() => { + expect(result.current.state.status).toBe('vault-missing') + }) + expect(result.current.state).toEqual({ + status: 'vault-missing', + vaultPath: '/vault/deleted', + defaultPath: '/mock/Documents/Laputa', + }) + }) + + 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 === 'check_vault_exists') return false + if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa' + return null + }) + + const { result } = renderHook(() => useOnboarding('/vault/missing')) + + await waitFor(() => { + expect(result.current.state.status).toBe('welcome') + }) + + await act(async () => { + await result.current.handleCreateVault() + }) + + expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' }) + 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 === 'check_vault_exists') return false + if (cmd === 'create_getting_started_vault') throw 'Permission denied' + return null + }) + + const { result } = renderHook(() => useOnboarding('/vault/missing')) + + await waitFor(() => { + expect(result.current.state.status).toBe('welcome') + }) + + await act(async () => { + await result.current.handleCreateVault() + }) + + expect(result.current.error).toBe('Permission denied') + expect(result.current.state.status).toBe('welcome') + }) + + 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 === 'check_vault_exists') return false + return null + }) + vi.mocked(pickFolder).mockResolvedValue('/selected/folder') + + const { result } = renderHook(() => useOnboarding('/vault/missing')) + + await waitFor(() => { + expect(result.current.state.status).toBe('welcome') + }) + + await act(async () => { + await result.current.handleOpenFolder() + }) + + expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/selected/folder' }) + expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1') + }) + + 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 === 'check_vault_exists') return false + return null + }) + vi.mocked(pickFolder).mockResolvedValue(null) + + const { result } = renderHook(() => useOnboarding('/vault/missing')) + + await waitFor(() => { + expect(result.current.state.status).toBe('welcome') + }) + + await act(async () => { + await result.current.handleOpenFolder() + }) + + expect(result.current.state.status).toBe('welcome') + }) + + 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 === 'check_vault_exists') return false + return null + }) + + const { result } = renderHook(() => useOnboarding('/vault/missing')) + + await waitFor(() => { + expect(result.current.state.status).toBe('welcome') + }) + + act(() => { + result.current.handleDismiss() + }) + + expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/missing' }) + expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1') + }) + + it('falls back to ready if commands fail', async () => { + mockInvokeFn.mockRejectedValue(new Error('command not found')) + + const { result } = renderHook(() => useOnboarding('/vault/path')) + + await waitFor(() => { + expect(result.current.state.status).toBe('ready') + }) + }) +}) diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts new file mode 100644 index 00000000..cc63b280 --- /dev/null +++ b/src/hooks/useOnboarding.ts @@ -0,0 +1,98 @@ +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' + +type OnboardingState = + | { status: 'loading' } + | { status: 'welcome'; defaultPath: string } + | { status: 'vault-missing'; vaultPath: string; defaultPath: string } + | { status: 'ready'; vaultPath: string } + +function tauriCall(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(command, args) +} + +const DISMISSED_KEY = 'laputa_welcome_dismissed' + +function wasDismissed(): boolean { + try { + return localStorage.getItem(DISMISSED_KEY) === '1' + } catch { + return false + } +} + +function markDismissed(): void { + try { + localStorage.setItem(DISMISSED_KEY, '1') + } catch { + // localStorage may be unavailable in some contexts + } +} + +export function useOnboarding(initialVaultPath: string) { + const [state, setState] = useState({ status: 'loading' }) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + async function check() { + try { + const defaultPath = await tauriCall('get_default_vault_path', {}) + const exists = await tauriCall('check_vault_exists', { path: initialVaultPath }) + + if (cancelled) return + + if (exists) { + setState({ status: 'ready', vaultPath: initialVaultPath }) + } else if (wasDismissed()) { + // User previously dismissed — show vault-missing instead of welcome + setState({ status: 'vault-missing', vaultPath: initialVaultPath, defaultPath }) + } else { + setState({ status: 'welcome', defaultPath }) + } + } catch { + // If commands fail (e.g. mock mode), just proceed + if (!cancelled) setState({ status: 'ready', vaultPath: initialVaultPath }) + } + } + + check() + return () => { cancelled = true } + }, [initialVaultPath]) + + const handleCreateVault = useCallback(async () => { + setCreating(true) + setError(null) + try { + const vaultPath = await tauriCall('create_getting_started_vault', { targetPath: null }) + markDismissed() + setState({ status: 'ready', vaultPath }) + } catch (err) { + setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`) + } finally { + setCreating(false) + } + }, []) + + const handleOpenFolder = useCallback(async () => { + try { + const path = await pickFolder('Open vault folder') + if (!path) return + markDismissed() + setState({ status: 'ready', vaultPath: path }) + } catch (err) { + setError(`Failed to open folder: ${err}`) + } + }, []) + + const handleDismiss = useCallback(() => { + markDismissed() + setState({ status: 'ready', vaultPath: initialVaultPath }) + }, [initialVaultPath]) + + return { state, creating, error, handleCreateVault, handleOpenFolder, handleDismiss } +} diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index bbeb795e..6048dcfd 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -228,6 +228,12 @@ export const mockHandlers: Record any> = { })) return { results: matches, elapsed_ms: 42, query: q, mode: args.mode } }, + get_default_vault_path: () => '/Users/mock/Documents/Laputa', + 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', } export function addMockEntry(_entry: VaultEntry, content: string): void {