feat: add empty vault creation flow
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user