From 7eaf57d040bb6fc07e74ef6fbca6a368ca3c2a45 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 15 Apr 2026 14:55:56 +0200 Subject: [PATCH] fix: make getting started clone destination explicit --- docs/ABSTRACTIONS.md | 7 +- docs/ARCHITECTURE.md | 4 +- docs/GETTING-STARTED.md | 4 +- src/App.tsx | 34 +- src/hooks/useGettingStartedClone.test.ts | 73 +++++ src/hooks/useGettingStartedClone.ts | 37 +++ src/hooks/useOnboarding.test.ts | 294 ++++++++---------- src/hooks/useOnboarding.ts | 37 +-- src/utils/gettingStartedVault.test.ts | 35 +++ src/utils/gettingStartedVault.ts | 38 +++ tests/smoke/getting-started-template.spec.ts | 10 +- tests/smoke/offline-onboarding-status.spec.ts | 10 +- 12 files changed, 370 insertions(+), 213 deletions(-) create mode 100644 src/hooks/useGettingStartedClone.test.ts create mode 100644 src/hooks/useGettingStartedClone.ts create mode 100644 src/utils/gettingStartedVault.test.ts create mode 100644 src/utils/gettingStartedVault.ts 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()