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:
1
(HOME)/.tauri/laputa-v2.key
Normal file
1
(HOME)/.tauri/laputa-v2.key
Normal file
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg==
|
||||
1
(HOME)/.tauri/laputa-v2.key.pub
Normal file
1
(HOME)/.tauri/laputa-v2.key.pub
Normal file
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK
|
||||
@@ -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 () => {
|
||||
|
||||
47
src/App.tsx
47
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 (
|
||||
<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} />
|
||||
|
||||
110
src/components/WelcomeScreen.test.tsx
Normal file
110
src/components/WelcomeScreen.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
206
src/components/WelcomeScreen.tsx
Normal file
206
src/components/WelcomeScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
204
src/hooks/useOnboarding.test.ts
Normal file
204
src/hooks/useOnboarding.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
98
src/hooks/useOnboarding.ts
Normal file
98
src/hooks/useOnboarding.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user