diff --git a/src-tauri/src/commands/vault.rs b/src-tauri/src/commands/vault.rs index 1020d260..6965d034 100644 --- a/src-tauri/src/commands/vault.rs +++ b/src-tauri/src/commands/vault.rs @@ -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()); + } } diff --git a/src/App.tsx b/src/App.tsx index bb0c8199..e98c867b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { - 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() }} /> + 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() }} /> setToastMessage(null)} /> @@ -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} diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 031f1762..92f4d043 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -154,6 +154,24 @@ describe('StatusBar', () => { expect(onOpenLocalFolder).toHaveBeenCalledOnce() }) + it('shows "Create empty vault" option in vault menu', () => { + render( + + ) + 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( + + ) + 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( { 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() }) diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 884baee0..4949c044 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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} diff --git a/src/components/WelcomeScreen.test.tsx b/src/components/WelcomeScreen.test.tsx index 088bb64c..310240f3 100644 --- a/src/components/WelcomeScreen.test.tsx +++ b/src/components/WelcomeScreen.test.tsx @@ -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() - 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() + it('calls onCreateEmptyVault when create empty button is clicked', () => { + const onCreateEmptyVault = vi.fn() + render() fireEvent.click(screen.getByTestId('welcome-create-new')) - expect(onCreateNewVault).toHaveBeenCalledOnce() + expect(onCreateEmptyVault).toHaveBeenCalledOnce() }) it('calls onCreateVault when template button is clicked', () => { diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index d8628e0d..8351cf5b 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -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({ } 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" diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index cb0b9a35..88761b37 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -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} diff --git a/src/components/status-bar/VaultMenu.tsx b/src/components/status-bar/VaultMenu.tsx index 620e9ad7..82faf085 100644 --- a/src/components/status-bar/VaultMenu.tsx +++ b/src/components/status-bar/VaultMenu.tsx @@ -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): VaultAction[] { +}: Pick): VaultAction[] { const items: VaultAction[] = [] + if (onCreateEmptyVault) { + items.push({ + key: 'create-empty', + icon: , + 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(() => { 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 }) +})