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 (
+
+ )
+ }
+
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}
+
+
+ )}
+
+
+
+
+
setHoverPrimary(true)}
+ onMouseLeave={() => setHoverPrimary(false)}
+ data-testid="welcome-create-vault"
+ >
+ {creating ? : }
+ {creating ? 'Creating vault…' : 'Create Getting Started vault'}
+
+
+
setHoverSecondary(true)}
+ onMouseLeave={() => setHoverSecondary(false)}
+ data-testid="welcome-open-folder"
+ >
+
+ {isWelcome ? 'Open an existing folder' : 'Choose a different folder'}
+
+
+
+ {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 {