feat: add empty vault creation flow

This commit is contained in:
lucaronin
2026-04-19 01:06:40 +02:00
parent aed4d05f1d
commit 67a9930ea1
18 changed files with 450 additions and 79 deletions

View File

@@ -29,4 +29,21 @@ describe('buildSettingsCommands', () => {
enabled: true,
})
})
it('adds a create-empty-vault command when the handler is available', () => {
const onOpenSettings = vi.fn()
const onCreateEmptyVault = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings, onCreateEmptyVault })
const command = commands.find((item) => item.id === 'create-empty-vault')
expect(command).toMatchObject({
label: 'Create Empty Vault…',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
})
})

View File

@@ -7,6 +7,7 @@ interface SettingsCommandsConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCheckForUpdates?: () => void
@@ -39,10 +40,12 @@ function buildVaultSettingsCommands({
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onCreateEmptyVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
return [
{ id: 'create-empty-vault', label: 'Create Empty Vault…', group: 'Settings', keywords: ['vault', 'create', 'new', 'empty', 'folder'], enabled: !!onCreateEmptyVault, execute: () => onCreateEmptyVault?.() },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
@@ -65,7 +68,7 @@ function buildMaintenanceCommands({
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
} = config
@@ -75,6 +78,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}),

View File

@@ -51,6 +51,7 @@ interface AppCommandsConfig {
canGoBack?: boolean
canGoForward?: boolean
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
@@ -185,6 +186,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
onToggleRawEditor: config.onToggleRawEditor,
onToggleAIChat: config.onToggleAIChat,
onOpenVault: config.onOpenVault,
onCreateEmptyVault: config.onCreateEmptyVault,
activeNoteModified: config.activeNoteModified,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,

View File

@@ -55,6 +55,7 @@ interface CommandRegistryConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onCreateType?: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
@@ -93,7 +94,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,
@@ -146,7 +147,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
}),
...buildAiAgentCommands({
@@ -165,7 +166,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,

View File

@@ -239,7 +239,7 @@ describe('useOnboarding', () => {
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateNewVault()
await result.current.handleCreateEmptyVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
@@ -248,7 +248,7 @@ describe('useOnboarding', () => {
it('does nothing when the empty-vault picker is cancelled', async () => {
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleCreateNewVault()
await onboarding.handleCreateEmptyVault()
})
})

View File

@@ -131,7 +131,7 @@ export function useOnboarding(
await createTemplateVault(lastTemplatePath)
}, [createTemplateVault, lastTemplatePath])
const handleCreateNewVault = useCallback(async () => {
const handleCreateEmptyVault = useCallback(async () => {
try {
setError(null)
const path = await pickFolder('Choose where to create your vault')
@@ -174,7 +174,7 @@ export function useOnboarding(
canRetryTemplate: !!error && !!lastTemplatePath && creatingAction === null,
handleCreateVault,
retryCreateVault,
handleCreateNewVault,
handleCreateEmptyVault,
handleOpenFolder,
handleDismiss,
userReadyVaultPath,

View File

@@ -32,14 +32,17 @@ vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
type MockInvokeOverrides = {
checkVaultExists?: boolean
createEmptyVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
createGettingStartedVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
}
describe('useVaultSwitcher', () => {
const onSwitch = vi.fn()
const onToast = vi.fn()
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
// Re-set default implementation after resetAllMocks
const setMockInvokeBehavior = (overrides: MockInvokeOverrides = {}) => {
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
@@ -47,9 +50,37 @@ describe('useVaultSwitcher', () => {
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(true)
if (cmd === 'check_vault_exists') return Promise.resolve(overrides.checkVaultExists ?? true)
if (cmd === 'create_empty_vault' && overrides.createEmptyVault) {
return Promise.resolve().then(() => overrides.createEmptyVault?.(args as { targetPath: string }))
}
if (cmd === 'create_getting_started_vault' && overrides.createGettingStartedVault) {
return Promise.resolve().then(() => overrides.createGettingStartedVault?.(args as { targetPath: string }))
}
return Promise.resolve(null)
})
}
const renderLoadedVaultSwitcher = async () => {
const hook = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(hook.result.current.loaded).toBe(true)
})
return hook
}
const setWorkVaultWithHiddenGettingStarted = () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
}
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
setMockInvokeBehavior()
})
it('starts with default vaults', async () => {
@@ -206,6 +237,42 @@ describe('useVaultSwitcher', () => {
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
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')
setMockInvokeBehavior({
createEmptyVault: ({ targetPath }) => targetPath,
})
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleCreateEmptyVault()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_empty_vault', { targetPath: '/Users/luca/New Vault' })
expect(result.current.vaultPath).toBe('/Users/luca/New Vault')
expect(result.current.allVaults.some(v => v.path === '/Users/luca/New Vault')).toBe(true)
expect(onToast).toHaveBeenCalledWith('Vault "New Vault" created and opened')
})
it('shows a friendly toast when empty-vault creation targets a non-empty folder', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Busy Folder')
setMockInvokeBehavior({
createEmptyVault: () => Promise.reject('Choose an empty folder to create a new vault'),
})
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleCreateEmptyVault()
})
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(onToast).toHaveBeenCalledWith('Choose an empty folder to create a new vault')
})
describe('removeVault', () => {
it('removes an extra vault from the list', async () => {
mockVaultListStore = {
@@ -304,11 +371,7 @@ describe('useVaultSwitcher', () => {
describe('restoreGettingStarted', () => {
it('un-hides the Getting Started vault', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
setWorkVaultWithHiddenGettingStarted()
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
@@ -324,11 +387,7 @@ describe('useVaultSwitcher', () => {
})
it('switches to the Getting Started vault after restoring', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
setWorkVaultWithHiddenGettingStarted()
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
@@ -342,25 +401,13 @@ describe('useVaultSwitcher', () => {
})
it('attempts to create vault on disk if it does not exist', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') return Promise.resolve(expectedDefaultVaultPath)
return Promise.resolve(null)
setWorkVaultWithHiddenGettingStarted()
setMockInvokeBehavior({
checkVaultExists: false,
createGettingStartedVault: ({ targetPath }) => targetPath,
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.restoreGettingStarted()
@@ -371,27 +418,13 @@ describe('useVaultSwitcher', () => {
})
it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') {
return Promise.reject('git clone failed: fatal: unable to access')
}
return Promise.resolve(null)
setWorkVaultWithHiddenGettingStarted()
setMockInvokeBehavior({
checkVaultExists: false,
createGettingStartedVault: () => Promise.reject('git clone failed: fatal: unable to access'),
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.restoreGettingStarted()

View File

@@ -359,6 +359,21 @@ function formatGettingStartedRestoreError(err: unknown): string {
return `Could not prepare Getting Started vault: ${message}`
}
function formatCreateEmptyVaultError(err: unknown): string {
const message =
typeof err === 'string'
? err
: err instanceof Error
? err.message
: `${err}`
if (message.includes('Choose an empty folder')) {
return message
}
return `Could not create empty vault: ${message}`
}
async function ensureGettingStartedVaultReady(path: string): Promise<void> {
const exists = await tauriCall<boolean>('check_vault_exists', { path })
if (!exists) {
@@ -505,6 +520,25 @@ function useOpenLocalFolderAction(
}, [addAndSwitch, onToastRef])
}
function useCreateEmptyVaultAction(
addAndSwitch: (path: string, label: string) => void,
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
try {
const targetPath = await pickFolder('Choose where to create your vault')
if (!targetPath) return
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
const label = labelFromPath({ path: vaultPath })
addAndSwitch(vaultPath, label)
onToastRef.current(`Vault "${label}" created and opened`)
} catch (err) {
onToastRef.current(formatCreateEmptyVaultError(err))
}
}, [addAndSwitch, onToastRef])
}
function useRemoveVaultAction({
defaultVaults,
extraVaults,
@@ -589,6 +623,7 @@ function useVaultActions({
}, [addVault, switchVault])
return {
handleCreateEmptyVault: useCreateEmptyVaultAction(addAndSwitch, onToastRef),
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
removeVault: useRemoveVaultAction({
@@ -656,7 +691,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
hiddenDefaults,
extraVaults,
)
const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
const { handleCreateEmptyVault, handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
...persistedState,
allVaults,
defaultVaults,
@@ -668,6 +703,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
return {
allVaults,
defaultPath,
handleCreateEmptyVault,
handleOpenLocalFolder,
handleVaultCloned,
isGettingStartedHidden,