(() => {
return buildVaultActions({
+ onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
- }, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
+ }, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
diff --git a/src/hooks/commands/settingsCommands.test.ts b/src/hooks/commands/settingsCommands.test.ts
index 14fb116d..a7b190bd 100644
--- a/src/hooks/commands/settingsCommands.test.ts
+++ b/src/hooks/commands/settingsCommands.test.ts
@@ -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)
+ })
})
diff --git a/src/hooks/commands/settingsCommands.ts b/src/hooks/commands/settingsCommands.ts
index 86aee39d..ef3c1390 100644
--- a/src/hooks/commands/settingsCommands.ts
+++ b/src/hooks/commands/settingsCommands.ts
@@ -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): CommandAction[] {
+}: Pick): 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,
}),
diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts
index f3c9c8cd..4f188054 100644
--- a/src/hooks/useAppCommands.ts
+++ b/src/hooks/useAppCommands.ts
@@ -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 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,
diff --git a/src/hooks/useOnboarding.test.ts b/src/hooks/useOnboarding.test.ts
index b7a40b16..6256eef0 100644
--- a/src/hooks/useOnboarding.test.ts
+++ b/src/hooks/useOnboarding.test.ts
@@ -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()
})
})
diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts
index 3936e583..20ede0a4 100644
--- a/src/hooks/useOnboarding.ts
+++ b/src/hooks/useOnboarding.ts
@@ -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,
diff --git a/src/hooks/useVaultSwitcher.test.ts b/src/hooks/useVaultSwitcher.test.ts
index 1046b6de..a770ed73 100644
--- a/src/hooks/useVaultSwitcher.test.ts
+++ b/src/hooks/useVaultSwitcher.test.ts
@@ -32,14 +32,17 @@ vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
+type MockInvokeOverrides = {
+ checkVaultExists?: boolean
+ createEmptyVault?: (args: { targetPath: string }) => Promise | unknown
+ createGettingStartedVault?: (args: { targetPath: string }) => Promise | 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): Promise => {
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) => {
- 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) => {
- 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()
diff --git a/src/hooks/useVaultSwitcher.ts b/src/hooks/useVaultSwitcher.ts
index aa93e95e..ef203ffd 100644
--- a/src/hooks/useVaultSwitcher.ts
+++ b/src/hooks/useVaultSwitcher.ts
@@ -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 {
const exists = await tauriCall('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('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,
diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts
index c2f8205d..c17bd4f7 100644
--- a/src/mock-tauri/mock-handlers.ts
+++ b/src/mock-tauri/mock-handlers.ts
@@ -372,7 +372,7 @@ export const mockHandlers: Record any> = {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
- create_empty_vault: (args: { target_path: string }) => args.target_path || '/Users/mock/Documents/My Vault',
+ create_empty_vault: (args: { targetPath?: string; target_path?: string }) => args.targetPath || args.target_path || '/Users/mock/Documents/My Vault',
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
diff --git a/tests/smoke/create-empty-vault-flows.spec.ts b/tests/smoke/create-empty-vault-flows.spec.ts
new file mode 100644
index 00000000..2391444f
--- /dev/null
+++ b/tests/smoke/create-empty-vault-flows.spec.ts
@@ -0,0 +1,195 @@
+import { test, expect, type Page } from '@playwright/test'
+import { findCommand, openCommandPalette, sendShortcut } from './helpers'
+
+interface MockEntry {
+ path: string
+ filename: string
+ title: string
+ isA: string
+ aliases: string[]
+ belongsTo: string[]
+ relatedTo: string[]
+ status: string | null
+ archived: boolean
+ modifiedAt: number | null
+ createdAt: number | null
+ fileSize: number
+ snippet: string
+ wordCount: number
+ relationships: Record
+ outgoingLinks: string[]
+ properties: Record
+ template: null
+ sort: null
+}
+
+interface VaultSeed {
+ label: string
+ path: string
+ noteTitle?: string
+}
+
+function untitledNoteRow(page: Page) {
+ return page.getByText(/^Untitled Note(?: \d+)?$/i).first()
+}
+
+async function installEmptyVaultMocks(
+ page: Page,
+ config: {
+ createdVaultPath: string
+ initialVaults: VaultSeed[]
+ activeVault: string | null
+ },
+) {
+ await page.addInitScript((mockConfig) => {
+ const gettingStartedPath = '/Users/mock/Documents/Getting Started'
+
+ function buildEntry(vaultPath: string, title: string): MockEntry {
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
+ return {
+ path: `${vaultPath}/${slug}.md`,
+ filename: `${slug}.md`,
+ title,
+ isA: 'Note',
+ aliases: [],
+ belongsTo: [],
+ relatedTo: [],
+ status: null,
+ archived: false,
+ modifiedAt: 1700000000,
+ createdAt: null,
+ fileSize: 256,
+ snippet: `${title} snippet`,
+ wordCount: 12,
+ relationships: {},
+ outgoingLinks: [],
+ properties: {},
+ template: null,
+ sort: null,
+ }
+ }
+
+ let savedVaults = mockConfig.initialVaults.map(({ label, path }) => ({ label, path }))
+ let activeVault = mockConfig.activeVault
+ let hiddenDefaults: string[] = []
+
+ const entriesByVault = Object.fromEntries(
+ mockConfig.initialVaults.map((vault) => [
+ vault.path,
+ vault.noteTitle ? [buildEntry(vault.path, vault.noteTitle)] : [],
+ ]),
+ ) satisfies Record
+
+ const allContent = Object.fromEntries(
+ Object.values(entriesByVault)
+ .flat()
+ .map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
+ )
+
+ localStorage.clear()
+ localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
+
+ Object.defineProperty(window, 'prompt', {
+ configurable: true,
+ value: () => mockConfig.createdVaultPath,
+ })
+
+ let ref: Record | null = null
+
+ Object.defineProperty(window, '__mockHandlers', {
+ configurable: true,
+ set(value) {
+ ref = value as Record
+ ref.load_vault_list = () => ({
+ vaults: [...savedVaults],
+ active_vault: activeVault,
+ hidden_defaults: [...hiddenDefaults],
+ })
+ ref.save_vault_list = (args: {
+ list: {
+ vaults: Array<{ label: string; path: string }>
+ active_vault: string | null
+ hidden_defaults?: string[]
+ }
+ }) => {
+ savedVaults = [...args.list.vaults]
+ activeVault = args.list.active_vault
+ hiddenDefaults = [...(args.list.hidden_defaults ?? [])]
+ return null
+ }
+ ref.get_default_vault_path = () => gettingStartedPath
+ ref.check_vault_exists = (args: { path?: string }) =>
+ savedVaults.some((vault) => vault.path === args.path)
+ || args.path === mockConfig.createdVaultPath
+ ref.create_empty_vault = (args: { targetPath?: string | null }) => {
+ if (args.targetPath !== mockConfig.createdVaultPath) {
+ throw new Error(`Unexpected empty vault target: ${args.targetPath}`)
+ }
+ entriesByVault[mockConfig.createdVaultPath] ??= []
+ return mockConfig.createdVaultPath
+ }
+ ref.list_vault = (args: { path?: string }) => entriesByVault[args.path ?? activeVault ?? ''] ?? []
+ ref.list_vault_folders = () => []
+ ref.list_views = () => []
+ ref.get_all_content = () => allContent
+ ref.get_note_content = (args: { path?: string }) => allContent[args.path ?? ''] ?? ''
+ ref.get_modified_files = () => []
+ ref.get_file_history = () => []
+ },
+ get() {
+ return ref
+ },
+ })
+ }, config)
+}
+
+test('keyboard onboarding can create an empty vault and the first note', async ({ page }) => {
+ await installEmptyVaultMocks(page, {
+ createdVaultPath: '/Users/mock/Documents/Fresh Vault',
+ initialVaults: [],
+ activeVault: null,
+ })
+
+ await page.goto('/', { waitUntil: 'domcontentloaded' })
+
+ await expect(page.getByTestId('welcome-screen')).toBeVisible()
+ await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
+ await expect(page.getByTestId('welcome-create-new')).toBeFocused()
+
+ await page.keyboard.press('Enter')
+
+ await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
+ await sendShortcut(page, 'n', ['Control'])
+ await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
+})
+
+test('command palette and bottom bar expose empty-vault creation from the active app shell', async ({ page }) => {
+ await installEmptyVaultMocks(page, {
+ createdVaultPath: '/Users/mock/Documents/Client Vault',
+ initialVaults: [{ label: 'Work Vault', path: '/Users/mock/Work', noteTitle: 'Work Home' }],
+ activeVault: '/Users/mock/Work',
+ })
+
+ await page.goto('/', { waitUntil: 'domcontentloaded' })
+
+ await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
+ await expect(page.getByTestId('status-vault-trigger')).toContainText('Work Vault')
+
+ await openCommandPalette(page)
+ expect(await findCommand(page, 'Create Empty Vault')).toBe(true)
+ await page.keyboard.press('Escape')
+
+ const trigger = page.getByTestId('status-vault-trigger')
+ await trigger.focus()
+ await expect(trigger).toBeFocused()
+ await page.keyboard.press('Enter')
+
+ const createEmptyItem = page.getByTestId('vault-menu-create-empty')
+ await createEmptyItem.focus()
+ await expect(createEmptyItem).toBeFocused()
+ await page.keyboard.press('Enter')
+
+ await expect(trigger).toContainText('Client Vault')
+ await sendShortcut(page, 'n', ['Control'])
+ await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
+})