fix: make getting started clone destination explicit

This commit is contained in:
lucaronin
2026-04-15 14:55:56 +02:00
parent e142b4c8f9
commit 7eaf57d040
12 changed files with 370 additions and 213 deletions

View File

@@ -543,9 +543,14 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
`useOnboarding` hook detects first launch:
- If vault path doesn't exist → show `WelcomeScreen`
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen parent folder; Tolaria derives the final `Getting Started` child path before cloning
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
`useGettingStartedClone` encapsulates the non-onboarding Getting Started action:
- Opens the same parent-folder picker used by onboarding
- Derives the final `.../Getting Started` destination path
- Surfaces the resolved path through the app toast after a successful clone
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
- Only shows after vault onboarding has already resolved to a ready state

View File

@@ -433,10 +433,12 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
- **Create a new vault** → creates an empty git repo in a folder the user chooses
- **Open an existing folder** → system file picker
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL, delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions.
### Remote Clone & Auth Model

View File

@@ -102,6 +102,7 @@ tolaria/
│ │ ├── appCommandCatalog.ts # Shortcut combos + command metadata
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
│ │ ├── useSettings.ts # App settings
│ │ ├── useGettingStartedClone.ts # Shared Getting Started clone action
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
│ │ ├── useMcpBridge.ts # MCP WebSocket client
@@ -208,7 +209,8 @@ tolaria/
|------|---------------|
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and restoring the cloned Getting Started vault. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. |
| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. |
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
### Backend

View File

@@ -35,7 +35,7 @@ import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { generateCommitMessage } from './utils/commitMessage'
import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useAutoSync } from './hooks/useAutoSync'
@@ -44,6 +44,7 @@ import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useGettingStartedClone } from './hooks/useGettingStartedClone'
import { useNetworkStatus } from './hooks/useNetworkStatus'
import { useAppNavigation } from './hooks/useAppNavigation'
import { useAiActivity } from './hooks/useAiActivity'
@@ -152,12 +153,24 @@ function App() {
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
const { handleVaultCloned } = vaultSwitcher
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
handleVaultCloned(vaultPath, label)
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
}, [handleVaultCloned])
const cloneGettingStartedVault = useGettingStartedClone({
onError: (message) => setToastMessage(message),
onSuccess: handleGettingStartedVaultReady,
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
})
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
// When onboarding resolves to a different vault path, update the switcher
// The active vault path can temporarily come from onboarding before the
// persisted vault switcher catches up to the newly cloned starter vault.
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
// Git repo check: 'checking' | 'required' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
@@ -673,7 +686,7 @@ function App() {
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
onRestoreGettingStarted: cloneGettingStartedVault,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
@@ -735,10 +748,13 @@ function App() {
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
return (
<AiAgentsOnboardingView
statuses={aiAgentsStatus}
onContinue={aiAgentsOnboarding.dismissPrompt}
/>
<>
<AiAgentsOnboardingView
statuses={aiAgentsStatus}
onContinue={aiAgentsOnboarding.dismissPrompt}
/>
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
</>
)
}
@@ -852,7 +868,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />

View File

@@ -0,0 +1,73 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockInvokeFn = vi.fn()
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
import { pickFolder } from '../utils/vault-dialog'
import { useGettingStartedClone } from './useGettingStartedClone'
describe('useGettingStartedClone', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does nothing when the folder picker is cancelled', async () => {
vi.mocked(pickFolder).mockResolvedValue(null)
const onSuccess = vi.fn()
const onError = vi.fn()
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
await act(async () => {
await result.current()
})
expect(mockInvokeFn).not.toHaveBeenCalled()
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
})
it('clones into a child Getting Started folder and reports the canonical path', async () => {
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Documents')
mockInvokeFn.mockResolvedValue('/Users/luca/Documents/Getting Started')
const onSuccess = vi.fn()
const onError = vi.fn()
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
await act(async () => {
await result.current()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', {
targetPath: '/Users/luca/Documents/Getting Started',
})
expect(onSuccess).toHaveBeenCalledWith('/Users/luca/Documents/Getting Started', 'Getting Started')
expect(onError).not.toHaveBeenCalled()
})
it('surfaces a friendly message for download failures', async () => {
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Documents')
mockInvokeFn.mockRejectedValue('git clone failed: fatal: unable to access')
const onSuccess = vi.fn()
const onError = vi.fn()
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
await act(async () => {
await result.current()
})
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith('Could not download Getting Started vault. Check your connection and try again.')
})
})

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import {
buildGettingStartedVaultPath,
formatGettingStartedCloneError,
labelFromPath,
} from '../utils/gettingStartedVault'
interface UseGettingStartedCloneOptions {
onError: (message: string) => void
onSuccess: (path: string, label: string) => void
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
export function useGettingStartedClone({
onError,
onSuccess,
}: UseGettingStartedCloneOptions) {
return useCallback(async () => {
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
if (!parentPath) return
const targetPath = buildGettingStartedVaultPath(parentPath)
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
onSuccess(vaultPath, labelFromPath(vaultPath))
} catch (err) {
onError(formatGettingStartedCloneError(err))
}
}, [onError, onSuccess])
}

View File

@@ -1,7 +1,14 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
const DEFAULT_GETTING_STARTED_PATH = '/mock/Documents/Getting Started'
const DEFAULT_PARENT_PATH = '/mock/Documents'
const MISSING_VAULT_PATH = '/vault/missing'
type MockArgs = Record<string, unknown> | undefined
type MockOverride = unknown | ((args?: MockArgs) => unknown)
// localStorage mock
const localStorageMock = (() => {
let store: Record<string, string> = {}
@@ -21,15 +28,49 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('./useVaultSwitcher', () => ({
}))
vi.mock('./useVaultSwitcher', () => ({}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
import { useOnboarding } from './useOnboarding'
import { pickFolder } from '../utils/vault-dialog'
import { useOnboarding } from './useOnboarding'
function mockCommands(overrides: Record<string, MockOverride> = {}) {
mockInvokeFn.mockImplementation(async (cmd: string, args?: MockArgs) => {
const override = overrides[cmd]
if (typeof override === 'function') {
return override(args)
}
if (override !== undefined) {
return override
}
if (cmd === 'get_default_vault_path') return DEFAULT_GETTING_STARTED_PATH
if (cmd === 'check_vault_exists') return false
return null
})
}
async function renderOnboarding(
initialVaultPath = MISSING_VAULT_PATH,
onTemplateVaultReady?: (vaultPath: string) => void,
) {
const rendered = renderHook(() => useOnboarding(initialVaultPath, onTemplateVaultReady))
await waitFor(() => {
expect(rendered.result.current.state.status).not.toBe('loading')
})
return rendered
}
async function expectStatus(
result: { current: ReturnType<typeof useOnboarding> },
status: 'ready' | 'welcome' | 'vault-missing',
) {
await waitFor(() => {
expect(result.current.state.status).toBe(status)
})
}
describe('useOnboarding', () => {
beforeEach(() => {
@@ -37,80 +78,49 @@ describe('useOnboarding', () => {
localStorage.clear()
})
it('transitions to ready when vault exists', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return true
return null
})
it('transitions to ready when the vault already exists', async () => {
mockCommands({ check_vault_exists: true })
const { result } = renderHook(() => useOnboarding('/vault/path'))
const { result } = await renderOnboarding('/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/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
it('shows the welcome screen when the vault does not exist', async () => {
mockCommands()
const { result } = renderHook(() => useOnboarding('/vault/missing'))
const { result } = await renderOnboarding()
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Getting Started' })
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
})
it('shows vault-missing when previously dismissed and vault gone', async () => {
it('shows vault-missing when the welcome screen was previously dismissed', async () => {
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
mockCommands()
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
const { result } = await renderOnboarding('/vault/deleted')
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/Getting Started',
defaultPath: DEFAULT_GETTING_STARTED_PATH,
})
})
it('clears the persisted active vault when the saved path no longer exists', async () => {
localStorage.setItem(LEGACY_APP_STORAGE_KEYS.welcomeDismissed, '1')
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'load_vault_list') {
return {
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
active_vault: '/vault/deleted',
hidden_defaults: [],
}
}
if (cmd === 'save_vault_list') return null
return null
mockCommands({
load_vault_list: {
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
active_vault: '/vault/deleted',
hidden_defaults: [],
},
save_vault_list: null,
})
const { result } = renderHook(() => useOnboarding('/vault/deleted'))
await waitFor(() => {
expect(result.current.state.status).toBe('vault-missing')
})
const { result } = await renderOnboarding('/vault/deleted')
await expectStatus(result, 'vault-missing')
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', {
list: {
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
@@ -120,46 +130,35 @@ describe('useOnboarding', () => {
})
})
it('handleCreateVault creates vault and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') return (args as { targetPath: string }).targetPath
return null
it('creates the template vault inside the selected parent folder', async () => {
const onTemplateVaultReady = vi.fn()
mockCommands({
create_getting_started_vault: (args?: MockArgs) => (args as { targetPath: string }).targetPath,
})
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
vi.mocked(pickFolder).mockResolvedValue(DEFAULT_PARENT_PATH)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding(MISSING_VAULT_PATH, onTemplateVaultReady)
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
expect(result.current.state).toEqual({ status: 'ready', vaultPath: DEFAULT_GETTING_STARTED_PATH })
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', {
targetPath: '/mock/Documents/Getting Started',
targetPath: DEFAULT_GETTING_STARTED_PATH,
})
expect(onTemplateVaultReady).toHaveBeenCalledWith(DEFAULT_GETTING_STARTED_PATH)
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('handleCreateVault does nothing when picker is cancelled', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
it('does nothing when the template folder picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateVault()
})
@@ -168,21 +167,15 @@ describe('useOnboarding', () => {
expect(mockInvokeFn).not.toHaveBeenCalledWith('create_getting_started_vault', expect.anything())
})
it('handleCreateVault sets a friendly download error on clone failure', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') throw 'git clone failed: fatal: unable to access'
return null
it('sets a friendly template error on clone failure', async () => {
mockCommands({
create_getting_started_vault: () => { throw 'git clone failed: fatal: unable to access' },
})
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
vi.mocked(pickFolder).mockResolvedValue(DEFAULT_PARENT_PATH)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateVault()
})
@@ -191,57 +184,46 @@ describe('useOnboarding', () => {
expect(result.current.state.status).toBe('welcome')
})
it('retryCreateVault reuses the last selected template path', async () => {
it('retries the last template clone without reopening the picker', async () => {
let attempts = 0
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') {
mockCommands({
create_getting_started_vault: (args?: MockArgs) => {
attempts += 1
if (attempts === 1) throw 'git clone failed: fatal: unable to access'
if (attempts === 1) {
throw 'git clone failed: fatal: unable to access'
}
return (args as { targetPath: string }).targetPath
}
return null
},
})
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
vi.mocked(pickFolder).mockResolvedValue(DEFAULT_PARENT_PATH)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateVault()
})
expect(result.current.canRetryTemplate).toBe(true)
await act(async () => {
await result.current.retryCreateVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
expect(result.current.state).toEqual({ status: 'ready', vaultPath: DEFAULT_GETTING_STARTED_PATH })
expect(mockInvokeFn).toHaveBeenLastCalledWith('create_getting_started_vault', {
targetPath: '/mock/Documents/Getting Started',
targetPath: DEFAULT_GETTING_STARTED_PATH,
})
})
it('handleCreateNewVault picks folder, creates empty vault, and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_empty_vault') return (args as { targetPath: string }).targetPath
return null
it('creates a new empty vault and transitions to ready', async () => {
mockCommands({
create_empty_vault: (args?: MockArgs) => (args as { targetPath: string }).targetPath,
})
vi.mocked(pickFolder).mockResolvedValue('/new/vault')
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateNewVault()
})
@@ -250,20 +232,13 @@ describe('useOnboarding', () => {
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('handleCreateNewVault does nothing when picker is cancelled', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
it('does nothing when the empty-vault picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateNewVault()
})
@@ -271,20 +246,13 @@ describe('useOnboarding', () => {
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/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
it('opens an existing folder and transitions to ready', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue('/selected/folder')
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleOpenFolder()
})
@@ -293,20 +261,13 @@ describe('useOnboarding', () => {
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).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/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
it('does nothing when the open-folder picker is cancelled', async () => {
mockCommands()
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleOpenFolder()
})
@@ -314,34 +275,25 @@ describe('useOnboarding', () => {
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/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {
mockCommands()
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
const { result } = await renderOnboarding()
await expectStatus(result, 'welcome')
act(() => {
result.current.handleDismiss()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/missing' })
expect(result.current.state).toEqual({ status: 'ready', vaultPath: MISSING_VAULT_PATH })
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('falls back to ready if commands fail', async () => {
it('falls back to ready if onboarding commands fail', async () => {
mockInvokeFn.mockRejectedValue(new Error('command not found'))
const { result } = renderHook(() => useOnboarding('/vault/path'))
const { result } = await renderOnboarding('/vault/path')
await waitFor(() => {
expect(result.current.state.status).toBe('ready')
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/path' })
})
})

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
import { buildGettingStartedVaultPath, formatGettingStartedCloneError } from '../utils/gettingStartedVault'
import { pickFolder } from '../utils/vault-dialog'
type OnboardingState =
@@ -39,26 +40,6 @@ function markDismissed(): void {
}
}
function formatTemplateError(err: unknown): string {
const message =
typeof err === 'string'
? err
: err instanceof Error
? err.message
: `${err}`
if (
message.includes('already exists and is not empty')
|| message.includes('already exists and is not a directory')
|| message.includes('Failed to create parent directory')
|| message.includes('Target path is required')
) {
return message
}
return 'Could not download Getting Started vault. Check your connection and try again.'
}
async function clearMissingActiveVault(missingPath: string): Promise<void> {
try {
const list = await tauriCall<PersistedVaultList>('load_vault_list', {})
@@ -75,7 +56,10 @@ async function clearMissingActiveVault(missingPath: string): Promise<void> {
}
}
export function useOnboarding(initialVaultPath: string) {
export function useOnboarding(
initialVaultPath: string,
onTemplateVaultReady?: (vaultPath: string) => void,
) {
const [state, setState] = useState<OnboardingState>({ status: 'loading' })
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
const [error, setError] = useState<string | null>(null)
@@ -126,17 +110,18 @@ export function useOnboarding(initialVaultPath: string) {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markDismissed()
setState({ status: 'ready', vaultPath })
onTemplateVaultReady?.(vaultPath)
} catch (err) {
setError(formatTemplateError(err))
setError(formatGettingStartedCloneError(err))
} finally {
setCreatingAction(null)
}
}, [])
}, [onTemplateVaultReady])
const handleCreateVault = useCallback(async () => {
const path = await pickFolder('Choose where to clone the Getting Started vault')
if (!path) return
await createTemplateVault(path)
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
if (!parentPath) return
await createTemplateVault(buildGettingStartedVaultPath(parentPath))
}, [createTemplateVault])
const retryCreateVault = useCallback(async () => {

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
GETTING_STARTED_VAULT_NAME,
buildGettingStartedVaultPath,
formatGettingStartedCloneError,
labelFromPath,
} from './gettingStartedVault'
describe('gettingStartedVault', () => {
it('builds a child vault path from a parent folder', () => {
expect(buildGettingStartedVaultPath('/Users/luca/Documents')).toBe('/Users/luca/Documents/Getting Started')
})
it('trims trailing separators when building the child vault path', () => {
expect(buildGettingStartedVaultPath('/Users/luca/Documents/')).toBe('/Users/luca/Documents/Getting Started')
})
it('preserves windows separators when building the child vault path', () => {
expect(buildGettingStartedVaultPath('C:\\Users\\luca\\Documents\\')).toBe('C:\\Users\\luca\\Documents\\Getting Started')
})
it('derives a label from the final path segment', () => {
expect(labelFromPath('/Users/luca/Documents/Getting Started')).toBe(GETTING_STARTED_VAULT_NAME)
})
it('passes through destination errors verbatim', () => {
expect(formatGettingStartedCloneError("Destination '/tmp/Getting Started' already exists and is not empty"))
.toBe("Destination '/tmp/Getting Started' already exists and is not empty")
})
it('converts other clone failures into a friendly download message', () => {
expect(formatGettingStartedCloneError('git clone failed: fatal: unable to access'))
.toBe('Could not download Getting Started vault. Check your connection and try again.')
})
})

View File

@@ -0,0 +1,38 @@
export const GETTING_STARTED_VAULT_NAME = 'Getting Started'
const CLONE_PATH_ERRORS = [
'already exists and is not empty',
'already exists and is not a directory',
'Failed to create parent directory',
'Target path is required',
]
export function buildGettingStartedVaultPath(parentPath: string): string {
const trimmed = parentPath.trim().replace(/[\\/]+$/g, '')
if (!trimmed) {
return GETTING_STARTED_VAULT_NAME
}
const separator = trimmed.includes('\\') && !trimmed.includes('/') ? '\\' : '/'
return `${trimmed}${separator}${GETTING_STARTED_VAULT_NAME}`
}
export function labelFromPath(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/g, '')
return trimmed.split(/[\\/]/).pop() || 'Vault'
}
export function formatGettingStartedCloneError(err: unknown): string {
const message =
typeof err === 'string'
? err
: err instanceof Error
? err.message
: `${err}`
if (CLONE_PATH_ERRORS.some(fragment => message.includes(fragment))) {
return message
}
return 'Could not download Getting Started vault. Check your connection and try again.'
}

View File

@@ -1,6 +1,8 @@
import { test, expect } from '@playwright/test'
test('Getting Started template shows inline retry on clone failure and opens after retry @smoke', async ({ page }) => {
const clonedPath = '/Users/mock/Documents/Getting Started'
await page.addInitScript(() => {
localStorage.clear()
@@ -23,7 +25,10 @@ test('Getting Started template shows inline retry on clone failure and opens aft
if (cloneAttempts === 1) {
throw 'git clone failed: fatal: unable to access'
}
return args.targetPath || '/Users/mock/Documents/Getting Started'
if (args.targetPath !== '/Users/mock/Documents/Getting Started') {
throw new Error(`Unexpected Getting Started target: ${args.targetPath}`)
}
return args.targetPath
}
},
get() {
@@ -33,7 +38,7 @@ test('Getting Started template shows inline retry on clone failure and opens aft
Object.defineProperty(window, 'prompt', {
configurable: true,
value: () => '/Users/mock/Documents/Getting Started',
value: () => '/Users/mock/Documents',
})
})
@@ -51,6 +56,7 @@ test('Getting Started template shows inline retry on clone failure and opens aft
await page.getByTestId('welcome-retry-template').click()
await expect(page.getByTestId('welcome-screen')).not.toBeVisible()
await expect(page.getByText(`Getting Started vault cloned and opened at ${clonedPath}`)).toBeVisible()
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
await expect(page.getByText('Claude Code not detected')).toBeVisible()
await page.getByTestId('claude-onboarding-continue').click()

View File

@@ -18,7 +18,10 @@ async function installOnboardingMocks(page: Page, offline: boolean) {
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
ref.check_vault_exists = () => false
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
return args.targetPath || '/Users/mock/Documents/Getting Started'
if (args.targetPath !== '/Users/mock/Documents/Getting Started') {
throw new Error(`Unexpected Getting Started target: ${args.targetPath}`)
}
return args.targetPath
}
},
get() {
@@ -28,7 +31,7 @@ async function installOnboardingMocks(page: Page, offline: boolean) {
Object.defineProperty(window, 'prompt', {
configurable: true,
value: () => '/Users/mock/Documents/Getting Started',
value: () => '/Users/mock/Documents',
})
Object.defineProperty(window.navigator, 'onLine', {
@@ -58,6 +61,9 @@ test('status bar keeps a Getting Started clone entry available after onboarding'
await page.getByTestId('welcome-create-vault').click()
await expect(page.getByText('Getting Started vault cloned and opened at /Users/mock/Documents/Getting Started')).toBeVisible()
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
await page.getByTestId('claude-onboarding-continue').click()
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
await page.getByTitle('Switch vault').click()
await expect(page.getByTestId('vault-menu-clone-getting-started')).toBeVisible()