feat: integrate welcome screen with tests and fix App tests

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 <noreply@anthropic.com>
This commit is contained in:
Test
2026-02-28 12:55:35 +01:00
parent bd0576c593
commit 3c57b0ff1a
9 changed files with 671 additions and 7 deletions

View File

@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg==

View File

@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK

View File

@@ -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 () => {

View File

@@ -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 (
<div className="app-shell">
<WelcomeScreen
mode={onboarding.state.status === 'welcome' ? 'welcome' : 'vault-missing'}
missingPath={onboarding.state.status === 'vault-missing' ? onboarding.state.vaultPath : undefined}
defaultVaultPath={defaultPath}
onCreateVault={onboarding.handleCreateVault}
onOpenFolder={onboarding.handleOpenFolder}
creating={onboarding.creating}
error={onboarding.error}
/>
</div>
)
}
// Show loading spinner while checking vault
if (onboarding.state.status === 'loading') {
return (
<div className="app-shell">
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading</span>
</div>
</div>
)
}
return (
<div className="app-shell">
<UpdateBanner status={updateStatus} actions={updateActions} />
@@ -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() {
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<SearchPanel open={dialogs.showSearch} vaultPath={vaultSwitcher.vaultPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />

View File

@@ -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(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Welcome to Laputa')).toBeInTheDocument()
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
})
it('shows create vault and open folder buttons', () => {
render(<WelcomeScreen {...defaultProps} />)
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(<WelcomeScreen {...defaultProps} />)
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(<WelcomeScreen {...defaultProps} onCreateVault={onCreateVault} />)
fireEvent.click(screen.getByTestId('welcome-create-vault'))
expect(onCreateVault).toHaveBeenCalledOnce()
})
it('calls onOpenFolder when open folder button is clicked', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
fireEvent.click(screen.getByTestId('welcome-open-folder'))
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('disables buttons while creating', () => {
render(<WelcomeScreen {...defaultProps} creating={true} />)
expect(screen.getByTestId('welcome-create-vault')).toBeDisabled()
expect(screen.getByTestId('welcome-open-folder')).toBeDisabled()
})
it('shows loading text while creating', () => {
render(<WelcomeScreen {...defaultProps} creating={true} />)
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Creating vault…')
})
it('shows error message when error is set', () => {
render(<WelcomeScreen {...defaultProps} error="Permission denied" />)
expect(screen.getByTestId('welcome-error')).toHaveTextContent('Permission denied')
})
it('does not show error when error is null', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.queryByTestId('welcome-error')).not.toBeInTheDocument()
})
it('does not show path badge in welcome mode', () => {
render(<WelcomeScreen {...defaultProps} />)
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(<WelcomeScreen {...missingProps} />)
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(<WelcomeScreen {...missingProps} />)
expect(screen.getByText('~/Laputa')).toBeInTheDocument()
})
it('shows "Choose a different folder" instead of "Open an existing folder"', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Choose a different folder')
})
it('does not show vault path hint in vault-missing mode', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.queryByText(/will be created in/)).not.toBeInTheDocument()
})
})
describe('data-testid', () => {
it('has welcome-screen container testid', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
})
})

View File

@@ -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 (
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
<div style={CARD_STYLE}>
<div
style={{
...ICON_WRAP_STYLE,
background: isWelcome ? 'var(--accent-blue-light, #EBF4FF)' : 'var(--accent-yellow-light, #FFF3E0)',
}}
>
{isWelcome
? <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}></span>
: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />
}
</div>
<div style={{ textAlign: 'center' }}>
<h1 style={TITLE_STYLE}>
{isWelcome ? 'Welcome to Laputa' : 'Vault not found'}
</h1>
<p style={{ ...SUBTITLE_STYLE, marginTop: 8 }}>
{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.'
}
</p>
</div>
{!isWelcome && missingPath && (
<div style={PATH_BADGE_STYLE}>
<code style={{ fontSize: 12, color: 'var(--muted-foreground)', fontFamily: 'var(--font-mono, monospace)' }}>
{missingPath}
</code>
</div>
)}
<div style={DIVIDER_STYLE} />
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
style={{
...PRIMARY_BTN_STYLE,
opacity: creating ? 0.7 : hoverPrimary ? 0.9 : 1,
}}
onClick={onCreateVault}
disabled={creating}
onMouseEnter={() => setHoverPrimary(true)}
onMouseLeave={() => setHoverPrimary(false)}
data-testid="welcome-create-vault"
>
{creating ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
{creating ? 'Creating vault…' : 'Create Getting Started vault'}
</button>
<button
style={{
...SECONDARY_BTN_STYLE,
background: hoverSecondary ? 'var(--sidebar)' : 'var(--background)',
}}
onClick={onOpenFolder}
disabled={creating}
onMouseEnter={() => setHoverSecondary(true)}
onMouseLeave={() => setHoverSecondary(false)}
data-testid="welcome-open-folder"
>
<FolderOpen size={16} />
{isWelcome ? 'Open an existing folder' : 'Choose a different folder'}
</button>
</div>
{error && <p style={ERROR_STYLE} data-testid="welcome-error">{error}</p>}
{isWelcome && !error && (
<p style={HINT_STYLE}>
The Getting Started vault will be created in {defaultVaultPath}
</p>
)}
</div>
</div>
)
}

View File

@@ -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<string, string> = {}
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')
})
})
})

View File

@@ -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<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(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<OnboardingState>({ status: 'loading' })
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function check() {
try {
const defaultPath = await tauriCall<string>('get_default_vault_path', {})
const exists = await tauriCall<boolean>('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<string>('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 }
}

View File

@@ -228,6 +228,12 @@ 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',
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 {