diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md
index de3d4c2e..3e326588 100644
--- a/docs/ABSTRACTIONS.md
+++ b/docs/ABSTRACTIONS.md
@@ -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
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2ea40509..b57f743e 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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
diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md
index ddb2e614..113943d9 100644
--- a/docs/GETTING-STARTED.md
+++ b/docs/GETTING-STARTED.md
@@ -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
diff --git a/src/App.tsx b/src/App.tsx
index b9f39913..7b694999 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 (
-
+ <>
+
+ setToastMessage(null)} />
+ >
)
}
@@ -852,7 +868,7 @@ function App() {
- 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() }} />
+ 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() }} />
setToastMessage(null)} />
diff --git a/src/hooks/useGettingStartedClone.test.ts b/src/hooks/useGettingStartedClone.test.ts
new file mode 100644
index 00000000..91affcd2
--- /dev/null
+++ b/src/hooks/useGettingStartedClone.test.ts
@@ -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.')
+ })
+})
diff --git a/src/hooks/useGettingStartedClone.ts b/src/hooks/useGettingStartedClone.ts
new file mode 100644
index 00000000..2d866ac1
--- /dev/null
+++ b/src/hooks/useGettingStartedClone.ts
@@ -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(command: string, args: Record): Promise {
+ return isTauri() ? invoke(command, args) : mockInvoke(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('create_getting_started_vault', { targetPath })
+ onSuccess(vaultPath, labelFromPath(vaultPath))
+ } catch (err) {
+ onError(formatGettingStartedCloneError(err))
+ }
+ }, [onError, onSuccess])
+}
diff --git a/src/hooks/useOnboarding.test.ts b/src/hooks/useOnboarding.test.ts
index 77e68087..8b6b295b 100644
--- a/src/hooks/useOnboarding.test.ts
+++ b/src/hooks/useOnboarding.test.ts
@@ -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 | undefined
+type MockOverride = unknown | ((args?: MockArgs) => unknown)
+
// localStorage mock
const localStorageMock = (() => {
let store: Record = {}
@@ -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 = {}) {
+ 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 },
+ 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) => {
- 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) => {
- 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) => {
- 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' })
})
})
diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts
index 0f0d3d1d..78309e73 100644
--- a/src/hooks/useOnboarding.ts
+++ b/src/hooks/useOnboarding.ts
@@ -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 {
try {
const list = await tauriCall('load_vault_list', {})
@@ -75,7 +56,10 @@ async function clearMissingActiveVault(missingPath: string): Promise {
}
}
-export function useOnboarding(initialVaultPath: string) {
+export function useOnboarding(
+ initialVaultPath: string,
+ onTemplateVaultReady?: (vaultPath: string) => void,
+) {
const [state, setState] = useState({ status: 'loading' })
const [creatingAction, setCreatingAction] = useState(null)
const [error, setError] = useState(null)
@@ -126,17 +110,18 @@ export function useOnboarding(initialVaultPath: string) {
const vaultPath = await tauriCall('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 () => {
diff --git a/src/utils/gettingStartedVault.test.ts b/src/utils/gettingStartedVault.test.ts
new file mode 100644
index 00000000..e8c59603
--- /dev/null
+++ b/src/utils/gettingStartedVault.test.ts
@@ -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.')
+ })
+})
diff --git a/src/utils/gettingStartedVault.ts b/src/utils/gettingStartedVault.ts
new file mode 100644
index 00000000..66906dc1
--- /dev/null
+++ b/src/utils/gettingStartedVault.ts
@@ -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.'
+}
diff --git a/tests/smoke/getting-started-template.spec.ts b/tests/smoke/getting-started-template.spec.ts
index 73513517..93510f77 100644
--- a/tests/smoke/getting-started-template.spec.ts
+++ b/tests/smoke/getting-started-template.spec.ts
@@ -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()
diff --git a/tests/smoke/offline-onboarding-status.spec.ts b/tests/smoke/offline-onboarding-status.spec.ts
index 18c5fd0a..bd0aad4d 100644
--- a/tests/smoke/offline-onboarding-status.spec.ts
+++ b/tests/smoke/offline-onboarding-status.spec.ts
@@ -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()