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

@@ -121,6 +121,7 @@ fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Re
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
@@ -129,6 +130,28 @@ fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Resu
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
@@ -441,10 +464,28 @@ mod tests {
assert!(result.is_ok());
assert!(vault_path.join(".git").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("CLAUDE.md").exists());
assert!(vault_path.join("config.md").exists());
assert!(vault_path.join("note.md").exists());
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(agents.contains("views/*.yml"));
}
#[test]
fn test_create_empty_vault_rejects_nonempty_target() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("existing-folder");
std::fs::create_dir_all(&vault_path).unwrap();
std::fs::write(vault_path.join("keep.txt"), "keep").unwrap();
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
let err = result.expect_err("expected non-empty folder to be rejected");
assert_eq!(err, "Choose an empty folder to create a new vault");
assert!(vault_path.join("keep.txt").exists());
assert!(!vault_path.join(".git").exists());
assert!(!vault_path.join("AGENTS.md").exists());
}
}

View File

@@ -1025,6 +1025,7 @@ function App() {
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: canGoBack, canGoForward: canGoForward,
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
@@ -1234,7 +1235,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
@@ -1298,7 +1299,7 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
defaultVaultPath={state.defaultPath}
onCreateVault={onboarding.handleCreateVault}
onRetryCreateVault={onboarding.retryCreateVault}
onCreateNewVault={onboarding.handleCreateNewVault}
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
onOpenFolder={onboarding.handleOpenFolder}
isOffline={isOffline}
creatingAction={onboarding.creatingAction}

View File

@@ -154,6 +154,24 @@ describe('StatusBar', () => {
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
})
it('shows "Create empty vault" option in vault menu', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
})
it('calls onCreateEmptyVault when clicking "Create empty vault"', () => {
const onCreateEmptyVault = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={onCreateEmptyVault} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByText('Create empty vault'))
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('shows add-vault options in vault menu', () => {
render(
<StatusBar
@@ -161,11 +179,13 @@ describe('StatusBar', () => {
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCreateEmptyVault={vi.fn()}
onOpenLocalFolder={vi.fn()}
onCloneVault={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
expect(screen.getByText('Open local folder')).toBeInTheDocument()
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
})

View File

@@ -21,6 +21,7 @@ interface StatusBarProps {
onSwitchVault: (path: string) => void
onOpenSettings?: () => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
@@ -60,6 +61,7 @@ export function StatusBar({
onSwitchVault,
onOpenSettings,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onClickPending,
@@ -121,6 +123,7 @@ export function StatusBar({
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}

View File

@@ -13,7 +13,7 @@ const defaultProps = {
defaultVaultPath: '~/Documents/Laputa',
onCreateVault: vi.fn(),
onRetryCreateVault: vi.fn(),
onCreateNewVault: vi.fn(),
onCreateEmptyVault: vi.fn(),
onOpenFolder: vi.fn(),
isOffline: false,
creatingAction: null as 'template' | 'empty' | null,
@@ -35,7 +35,7 @@ describe('WelcomeScreen', () => {
it('shows all three option buttons', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create a new vault')
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
})
@@ -56,11 +56,11 @@ describe('WelcomeScreen', () => {
expect(screen.getByText(/Requires internet — clone later/)).toBeInTheDocument()
})
it('calls onCreateNewVault when create new button is clicked', () => {
const onCreateNewVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)
it('calls onCreateEmptyVault when create empty button is clicked', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
fireEvent.click(screen.getByTestId('welcome-create-new'))
expect(onCreateNewVault).toHaveBeenCalledOnce()
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateVault when template button is clicked', () => {

View File

@@ -9,7 +9,7 @@ interface WelcomeScreenProps {
defaultVaultPath: string
onCreateVault: () => void
onRetryCreateVault: () => void
onCreateNewVault: () => void
onCreateEmptyVault: () => void
onOpenFolder: () => void
isOffline: boolean
creatingAction: 'template' | 'empty' | null
@@ -230,7 +230,7 @@ export function WelcomeScreen({
defaultVaultPath,
onCreateVault,
onRetryCreateVault,
onCreateNewVault,
onCreateEmptyVault,
onOpenFolder,
isOffline,
creatingAction,
@@ -269,11 +269,11 @@ export function WelcomeScreen({
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
label="Create a new vault"
description="Start fresh in a folder you choose"
label="Create empty vault"
description="Start fresh in an empty folder with Tolaria defaults"
loadingLabel="Creating vault…"
loadingDescription="Preparing an empty vault in the selected folder"
onClick={onCreateNewVault}
loadingDescription="Preparing Tolaria defaults in the selected folder"
onClick={onCreateEmptyVault}
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"

View File

@@ -35,6 +35,7 @@ interface StatusBarPrimarySectionProps {
vaults: VaultOption[]
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
@@ -77,6 +78,7 @@ export function StatusBarPrimarySection({
vaults,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onClickPending,
@@ -111,6 +113,7 @@ export function StatusBarPrimarySection({
vaultPath={vaultPath}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import type { VaultOption } from './types'
@@ -11,6 +11,7 @@ interface VaultMenuProps {
vaultPath: string
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
@@ -42,12 +43,24 @@ interface VaultAction {
}
function buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
}: Pick<VaultMenuProps, 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
}: Pick<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
const items: VaultAction[] = []
if (onCreateEmptyVault) {
items.push({
key: 'create-empty',
icon: <Plus size={12} />,
label: 'Create empty vault',
testId: 'vault-menu-create-empty',
accent: true,
onClick: onCreateEmptyVault,
})
}
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
@@ -158,6 +171,7 @@ export function VaultMenu({
vaultPath,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
@@ -171,11 +185,12 @@ export function VaultMenu({
const actions = useMemo<VaultAction[]>(() => {
return buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
}, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
}, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
<div ref={menuRef} style={{ position: 'relative' }}>

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,

View File

@@ -372,7 +372,7 @@ export const mockHandlers: Record<string, (args: any) => 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',

View File

@@ -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<string, string[]>
outgoingLinks: string[]
properties: Record<string, unknown>
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<string, MockEntry[]>
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<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
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 })
})