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

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