diff --git a/src/hooks/useGettingStartedClone.test.ts b/src/hooks/useGettingStartedClone.test.ts index 91affcd2..99f873c7 100644 --- a/src/hooks/useGettingStartedClone.test.ts +++ b/src/hooks/useGettingStartedClone.test.ts @@ -8,11 +8,15 @@ vi.mock('../mock-tauri', () => ({ mockInvoke: (...args: unknown[]) => mockInvokeFn(...args), })) -vi.mock('../utils/vault-dialog', () => ({ - pickFolder: vi.fn(), -})) +vi.mock('../utils/vault-dialog', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + pickFolder: vi.fn(), + } +}) -import { pickFolder } from '../utils/vault-dialog' +import { NativeFolderPickerBlockedError, pickFolder } from '../utils/vault-dialog' import { useGettingStartedClone } from './useGettingStartedClone' describe('useGettingStartedClone', () => { @@ -70,4 +74,21 @@ describe('useGettingStartedClone', () => { expect(onSuccess).not.toHaveBeenCalled() expect(onError).toHaveBeenCalledWith('Could not download Getting Started vault. Check your connection and try again.') }) + + it('surfaces the restart-required message when folder picking is blocked after update install', async () => { + vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError()) + + 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( + 'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.', + ) + }) }) diff --git a/src/hooks/useGettingStartedClone.ts b/src/hooks/useGettingStartedClone.ts index 2d866ac1..fbaf1532 100644 --- a/src/hooks/useGettingStartedClone.ts +++ b/src/hooks/useGettingStartedClone.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' -import { pickFolder } from '../utils/vault-dialog' +import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog' import { buildGettingStartedVaultPath, formatGettingStartedCloneError, @@ -22,7 +22,14 @@ export function useGettingStartedClone({ onSuccess, }: UseGettingStartedCloneOptions) { return useCallback(async () => { - const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault') + let parentPath: string | null + try { + parentPath = await pickFolder('Choose a parent folder for the Getting Started vault') + } catch (err) { + onError(formatFolderPickerActionError('Could not choose a parent folder', err)) + return + } + if (!parentPath) return const targetPath = buildGettingStartedVaultPath(parentPath) diff --git a/src/hooks/useOnboarding.test.ts b/src/hooks/useOnboarding.test.ts index 0d93ed96..4ccf1733 100644 --- a/src/hooks/useOnboarding.test.ts +++ b/src/hooks/useOnboarding.test.ts @@ -30,11 +30,15 @@ vi.mock('../mock-tauri', () => ({ vi.mock('./useVaultSwitcher', () => ({})) -vi.mock('../utils/vault-dialog', () => ({ - pickFolder: vi.fn(), -})) +vi.mock('../utils/vault-dialog', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + pickFolder: vi.fn(), + } +}) -import { pickFolder } from '../utils/vault-dialog' +import { NativeFolderPickerBlockedError, pickFolder } from '../utils/vault-dialog' import { useOnboarding } from './useOnboarding' function mockCommands(overrides: Record = {}) { @@ -314,6 +318,23 @@ describe('useOnboarding', () => { }) }) + it('shows the restart-required picker message instead of crashing the welcome flow', async () => { + mockCommands() + vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError()) + + const { result } = await renderOnboarding() + + await expectStatus(result, 'welcome') + await act(async () => { + await result.current.handleOpenFolder() + }) + + expect(result.current.error).toBe( + 'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.', + ) + expect(result.current.state.status).toBe('welcome') + }) + it('marks the welcome screen dismissed and keeps the initial vault path', async () => { mockCommands() diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts index d0a52367..8c821835 100644 --- a/src/hooks/useOnboarding.ts +++ b/src/hooks/useOnboarding.ts @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } 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' +import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog' type OnboardingState = | { status: 'loading' } @@ -12,6 +12,21 @@ type OnboardingState = | { status: 'ready'; vaultPath: string } type CreatingAction = 'template' | 'empty' | null +type SetError = Dispatch> +type SetCreatingAction = Dispatch> + +interface ReadyVaultHandlers { + setState: Dispatch> + setUserReadyVaultPath: Dispatch> +} + +interface TemplateVaultCreationOptions { + handlers: ReadyVaultHandlers + setCreatingAction: SetCreatingAction + setError: SetError + setLastTemplatePath: Dispatch> + onTemplateVaultReady?: (vaultPath: string) => void +} function tauriCall(command: string, args: Record): Promise { return isTauri() ? invoke(command, args) : mockInvoke(command, args) @@ -58,6 +73,107 @@ async function clearMissingActiveVault(missingPath: string): Promise { } } +function markVaultReady( + handlers: ReadyVaultHandlers, + vaultPath: string, +) { + markDismissed() + handlers.setState({ status: 'ready', vaultPath }) + handlers.setUserReadyVaultPath(vaultPath) +} + +async function pickFolderWithOnboardingError( + title: string, + setError: SetError, + action: string, +): Promise { + setError(null) + + try { + return await pickFolder(title) + } catch (err) { + setError(formatFolderPickerActionError(action, err)) + return null + } +} + +function useTemplateVaultCreation( + options: TemplateVaultCreationOptions, +) { + return useCallback(async (targetPath: string) => { + options.setCreatingAction('template') + options.setError(null) + options.setLastTemplatePath(targetPath) + + try { + const vaultPath = await tauriCall('create_getting_started_vault', { targetPath }) + markVaultReady(options.handlers, vaultPath) + options.onTemplateVaultReady?.(vaultPath) + } catch (err) { + options.setError(formatGettingStartedCloneError(err)) + } finally { + options.setCreatingAction(null) + } + }, [options]) +} + +function useCreateVaultHandler( + createTemplateVault: (targetPath: string) => Promise, + setError: SetError, +) { + return useCallback(async () => { + const parentPath = await pickFolderWithOnboardingError( + 'Choose a parent folder for the Getting Started vault', + setError, + 'Could not choose a parent folder', + ) + if (!parentPath) return + + await createTemplateVault(buildGettingStartedVaultPath(parentPath)) + }, [createTemplateVault, setError]) +} + +function useCreateEmptyVaultHandler( + handlers: ReadyVaultHandlers, + setCreatingAction: SetCreatingAction, + setError: SetError, +) { + return useCallback(async () => { + const path = await pickFolderWithOnboardingError( + 'Choose where to create your vault', + setError, + 'Could not choose where to create your vault', + ) + if (!path) return + + try { + setCreatingAction('empty') + const vaultPath = await tauriCall('create_empty_vault', { targetPath: path }) + markVaultReady(handlers, vaultPath) + } catch (err) { + setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`) + } finally { + setCreatingAction(null) + } + }, [handlers, setCreatingAction, setError]) +} + +function useOpenFolderHandler( + handlers: ReadyVaultHandlers, + setError: SetError, +) { + return useCallback(async () => { + const path = await pickFolderWithOnboardingError( + 'Open vault folder', + setError, + 'Failed to open folder', + ) + if (!path) return + + markVaultReady(handlers, path) + }, [handlers, setError]) +} + export function useOnboarding( initialVaultPath: string, onTemplateVaultReady?: (vaultPath: string) => void, @@ -68,12 +184,12 @@ export function useOnboarding( const [error, setError] = useState(null) const [lastTemplatePath, setLastTemplatePath] = useState(null) const [userReadyVaultPath, setUserReadyVaultPath] = useState(null) + const readyVaultHandlers = { setState, setUserReadyVaultPath } useEffect(() => { let cancelled = false if (!initialVaultResolved) { - setState({ status: 'loading' }) return () => { cancelled = true } } @@ -108,71 +224,38 @@ export function useOnboarding( return () => { cancelled = true } }, [initialVaultPath, initialVaultResolved]) - const createTemplateVault = useCallback(async (targetPath: string) => { - setCreatingAction('template') - setError(null) - setLastTemplatePath(targetPath) - try { - const vaultPath = await tauriCall('create_getting_started_vault', { targetPath }) - markDismissed() - setState({ status: 'ready', vaultPath }) - setUserReadyVaultPath(vaultPath) - onTemplateVaultReady?.(vaultPath) - } catch (err) { - setError(formatGettingStartedCloneError(err)) - } finally { - setCreatingAction(null) - } - }, [onTemplateVaultReady]) + const createTemplateVault = useTemplateVaultCreation({ + handlers: readyVaultHandlers, + setCreatingAction, + setError, + setLastTemplatePath, + onTemplateVaultReady, + }) - const handleCreateVault = useCallback(async () => { - const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault') - if (!parentPath) return - await createTemplateVault(buildGettingStartedVaultPath(parentPath)) - }, [createTemplateVault]) + const handleCreateVault = useCreateVaultHandler(createTemplateVault, setError) const retryCreateVault = useCallback(async () => { if (!lastTemplatePath) return await createTemplateVault(lastTemplatePath) }, [createTemplateVault, lastTemplatePath]) - const handleCreateEmptyVault = useCallback(async () => { - try { - setError(null) - const path = await pickFolder('Choose where to create your vault') - if (!path) return - setCreatingAction('empty') - const vaultPath = await tauriCall('create_empty_vault', { targetPath: path }) - markDismissed() - setState({ status: 'ready', vaultPath }) - setUserReadyVaultPath(vaultPath) - } catch (err) { - setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`) - } finally { - setCreatingAction(null) - } - }, []) + const handleCreateEmptyVault = useCreateEmptyVaultHandler( + readyVaultHandlers, + setCreatingAction, + setError, + ) - const handleOpenFolder = useCallback(async () => { - try { - setError(null) - const path = await pickFolder('Open vault folder') - if (!path) return - markDismissed() - setState({ status: 'ready', vaultPath: path }) - setUserReadyVaultPath(path) - } catch (err) { - setError(`Failed to open folder: ${err}`) - } - }, []) + const handleOpenFolder = useOpenFolderHandler(readyVaultHandlers, setError) const handleDismiss = useCallback(() => { markDismissed() setState({ status: 'ready', vaultPath: initialVaultPath }) }, [initialVaultPath]) + const resolvedState = initialVaultResolved ? state : { status: 'loading' as const } + return { - state, + state: resolvedState, creating: creatingAction !== null, creatingAction, error, diff --git a/src/hooks/useUpdater.test.ts b/src/hooks/useUpdater.test.ts index 7316c5fd..f08028cb 100644 --- a/src/hooks/useUpdater.test.ts +++ b/src/hooks/useUpdater.test.ts @@ -1,6 +1,10 @@ import { renderHook, act } from '@testing-library/react' import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { useUpdater } from './useUpdater' +import { + clearRestartRequiredAfterUpdate, + isRestartRequiredAfterUpdate, +} from '../lib/appUpdater' vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(() => false), @@ -108,6 +112,7 @@ describe('useUpdater', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + clearRestartRequiredAfterUpdate() vi.spyOn(console, 'warn').mockImplementation(() => {}) }) @@ -270,6 +275,7 @@ describe('useUpdater', () => { version: '2026.4.16', displayVersion: '2026.4.16', }) + expect(isRestartRequiredAfterUpdate()).toBe(true) }) it('transitions to error when download fails', async () => { diff --git a/src/hooks/useVaultSwitcher.test.ts b/src/hooks/useVaultSwitcher.test.ts index 14e09693..5d1f26f9 100644 --- a/src/hooks/useVaultSwitcher.test.ts +++ b/src/hooks/useVaultSwitcher.test.ts @@ -28,9 +28,15 @@ vi.mock('../mock-tauri', () => ({ mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), })) -vi.mock('../utils/vault-dialog', () => ({ - pickFolder: vi.fn(), -})) +vi.mock('../utils/vault-dialog', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + pickFolder: vi.fn(), + } +}) + +import { NativeFolderPickerBlockedError } from '../utils/vault-dialog' type MockInvokeOverrides = { checkVaultExists?: boolean | ((args: { path?: string }) => boolean) @@ -294,6 +300,21 @@ describe('useVaultSwitcher', () => { expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened') }) + it('shows a clear toast when folder picking is blocked until restart', async () => { + const { pickFolder } = await import('../utils/vault-dialog') + vi.mocked(pickFolder).mockRejectedValue(new NativeFolderPickerBlockedError()) + + const { result } = await renderLoadedVaultSwitcher() + + await act(async () => { + await result.current.handleOpenLocalFolder() + }) + + expect(onToast).toHaveBeenCalledWith( + 'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.', + ) + }) + it('creates an empty vault and switches to it', async () => { const { pickFolder } = await import('../utils/vault-dialog') vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault') diff --git a/src/hooks/useVaultSwitcher.ts b/src/hooks/useVaultSwitcher.ts index 08204b21..814dea2a 100644 --- a/src/hooks/useVaultSwitcher.ts +++ b/src/hooks/useVaultSwitcher.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' -import { pickFolder } from '../utils/vault-dialog' +import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog' import { loadVaultList, saveVaultList } from '../utils/vaultListStore' import type { VaultOption } from '../components/StatusBar' import { trackEvent } from '../lib/telemetry' @@ -650,7 +650,14 @@ function useOpenLocalFolderAction( onToastRef: MutableRefObject<(msg: string) => void>, ) { return useCallback(async () => { - const path = await pickFolder('Open vault folder') + let path: string | null + try { + path = await pickFolder('Open vault folder') + } catch (err) { + onToastRef.current(formatFolderPickerActionError('Could not open vault folder', err)) + return + } + if (!path) return const label = labelFromPath({ path }) @@ -664,10 +671,16 @@ function useCreateEmptyVaultAction( onToastRef: MutableRefObject<(msg: string) => void>, ) { return useCallback(async () => { + let targetPath: string | null try { - const targetPath = await pickFolder('Choose where to create your vault') - if (!targetPath) return + targetPath = await pickFolder('Choose where to create your vault') + } catch (err) { + onToastRef.current(formatFolderPickerActionError('Could not choose where to create your vault', err)) + return + } + try { + if (!targetPath) return const vaultPath = await tauriCall('create_empty_vault', { targetPath }) const label = labelFromPath({ path: vaultPath }) addAndSwitch(vaultPath, label) diff --git a/src/lib/appUpdater.ts b/src/lib/appUpdater.ts index 16f27cfb..09a0e751 100644 --- a/src/lib/appUpdater.ts +++ b/src/lib/appUpdater.ts @@ -13,6 +13,23 @@ export type AppUpdateDownloadEvent = | { event: 'Progress'; data: { chunkLength: number } } | { event: 'Finished' } +export const RESTART_REQUIRED_FOLDER_PICKER_MESSAGE = + 'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.' + +let restartRequiredAfterUpdate = false + +export function markRestartRequiredAfterUpdate(): void { + restartRequiredAfterUpdate = true +} + +export function clearRestartRequiredAfterUpdate(): void { + restartRequiredAfterUpdate = false +} + +export function isRestartRequiredAfterUpdate(): boolean { + return restartRequiredAfterUpdate +} + export async function checkForAppUpdate( releaseChannel: string | null | undefined, ): Promise { @@ -34,4 +51,5 @@ export async function downloadAndInstallAppUpdate( expectedVersion, onEvent: channel, }) + markRestartRequiredAfterUpdate() } diff --git a/src/utils/vault-dialog.test.ts b/src/utils/vault-dialog.test.ts index dee9543e..4c0712ba 100644 --- a/src/utils/vault-dialog.test.ts +++ b/src/utils/vault-dialog.test.ts @@ -5,8 +5,18 @@ vi.mock('../mock-tauri', () => ({ isTauri: vi.fn(() => false), })) +vi.mock('../lib/appUpdater', () => ({ + RESTART_REQUIRED_FOLDER_PICKER_MESSAGE: + 'Tolaria needs a restart before macOS can open another folder picker. Restart to apply the downloaded update and try again.', + isRestartRequiredAfterUpdate: vi.fn(() => false), +})) + import { pickFolder } from './vault-dialog' import { isTauri } from '../mock-tauri' +import { + isRestartRequiredAfterUpdate, + RESTART_REQUIRED_FOLDER_PICKER_MESSAGE, +} from '../lib/appUpdater' describe('pickFolder', () => { beforeEach(() => { @@ -37,4 +47,11 @@ describe('pickFolder', () => { await pickFolder() expect(window.prompt).toHaveBeenCalledWith('Enter folder path:') }) + + it('blocks the native folder picker when a restart is required after update install', async () => { + vi.mocked(isTauri).mockReturnValue(true) + vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(true) + + await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) + }) }) diff --git a/src/utils/vault-dialog.ts b/src/utils/vault-dialog.ts index 0c59c668..de240c87 100644 --- a/src/utils/vault-dialog.ts +++ b/src/utils/vault-dialog.ts @@ -5,6 +5,41 @@ */ import { isTauri } from '../mock-tauri' +import { + isRestartRequiredAfterUpdate, + RESTART_REQUIRED_FOLDER_PICKER_MESSAGE, +} from '../lib/appUpdater' + +export class NativeFolderPickerBlockedError extends Error { + constructor(message = RESTART_REQUIRED_FOLDER_PICKER_MESSAGE) { + super(message) + this.name = 'NativeFolderPickerBlockedError' + } +} + +export function isNativeFolderPickerBlockedError( + error: unknown, +): error is NativeFolderPickerBlockedError { + return error instanceof NativeFolderPickerBlockedError +} + +export function formatFolderPickerActionError( + action: string, + error: unknown, +): string { + if (isNativeFolderPickerBlockedError(error)) { + return error.message + } + + const message = + typeof error === 'string' + ? error + : error instanceof Error + ? error.message + : '' + + return message ? `${action}: ${message}` : action +} /** * Opens a native folder picker dialog (Tauri) or falls back to prompt (browser). @@ -12,6 +47,10 @@ import { isTauri } from '../mock-tauri' */ export async function pickFolder(title?: string): Promise { if (isTauri()) { + if (isRestartRequiredAfterUpdate()) { + throw new NativeFolderPickerBlockedError() + } + const { open } = await import('@tauri-apps/plugin-dialog') const selected = await open({ directory: true,