feat: add empty vault creation flow
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -29,4 +29,21 @@ describe('buildSettingsCommands', () => {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a create-empty-vault command when the handler is available', () => {
|
||||
const onOpenSettings = vi.fn()
|
||||
const onCreateEmptyVault = vi.fn()
|
||||
|
||||
const commands = buildSettingsCommands({ onOpenSettings, onCreateEmptyVault })
|
||||
const command = commands.find((item) => item.id === 'create-empty-vault')
|
||||
|
||||
expect(command).toMatchObject({
|
||||
label: 'Create Empty Vault…',
|
||||
enabled: true,
|
||||
group: 'Settings',
|
||||
})
|
||||
|
||||
command?.execute()
|
||||
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ interface SettingsCommandsConfig {
|
||||
onOpenSettings: () => void
|
||||
onOpenFeedback?: () => void
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onRemoveActiveVault?: () => void
|
||||
onRestoreGettingStarted?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
@@ -39,10 +40,12 @@ function buildVaultSettingsCommands({
|
||||
vaultCount,
|
||||
isGettingStartedHidden,
|
||||
onOpenVault,
|
||||
onCreateEmptyVault,
|
||||
onRemoveActiveVault,
|
||||
onRestoreGettingStarted,
|
||||
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
|
||||
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onCreateEmptyVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
|
||||
return [
|
||||
{ id: 'create-empty-vault', label: 'Create Empty Vault…', group: 'Settings', keywords: ['vault', 'create', 'new', 'empty', 'folder'], enabled: !!onCreateEmptyVault, execute: () => onCreateEmptyVault?.() },
|
||||
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
|
||||
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
|
||||
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
|
||||
@@ -65,7 +68,7 @@ function buildMaintenanceCommands({
|
||||
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
} = config
|
||||
|
||||
@@ -75,6 +78,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
|
||||
vaultCount,
|
||||
isGettingStartedHidden,
|
||||
onOpenVault,
|
||||
onCreateEmptyVault,
|
||||
onRemoveActiveVault,
|
||||
onRestoreGettingStarted,
|
||||
}),
|
||||
|
||||
@@ -51,6 +51,7 @@ interface AppCommandsConfig {
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
@@ -185,6 +186,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onCreateEmptyVault: config.onCreateEmptyVault,
|
||||
activeNoteModified: config.activeNoteModified,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
|
||||
@@ -55,6 +55,7 @@ interface CommandRegistryConfig {
|
||||
onOpenSettings: () => void
|
||||
onOpenFeedback?: () => void
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onDeleteNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
@@ -93,7 +94,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect,
|
||||
@@ -146,7 +147,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
}),
|
||||
...buildSettingsCommands({
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
}),
|
||||
...buildAiAgentCommands({
|
||||
@@ -165,7 +166,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
onCheckForUpdates,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect,
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateNewVault()
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
|
||||
@@ -248,7 +248,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('does nothing when the empty-vault picker is cancelled', async () => {
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleCreateNewVault()
|
||||
await onboarding.handleCreateEmptyVault()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export function useOnboarding(
|
||||
await createTemplateVault(lastTemplatePath)
|
||||
}, [createTemplateVault, lastTemplatePath])
|
||||
|
||||
const handleCreateNewVault = useCallback(async () => {
|
||||
const handleCreateEmptyVault = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const path = await pickFolder('Choose where to create your vault')
|
||||
@@ -174,7 +174,7 @@ export function useOnboarding(
|
||||
canRetryTemplate: !!error && !!lastTemplatePath && creatingAction === null,
|
||||
handleCreateVault,
|
||||
retryCreateVault,
|
||||
handleCreateNewVault,
|
||||
handleCreateEmptyVault,
|
||||
handleOpenFolder,
|
||||
handleDismiss,
|
||||
userReadyVaultPath,
|
||||
|
||||
@@ -32,14 +32,17 @@ vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
type MockInvokeOverrides = {
|
||||
checkVaultExists?: boolean
|
||||
createEmptyVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
|
||||
createGettingStartedVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
describe('useVaultSwitcher', () => {
|
||||
const onSwitch = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
// Re-set default implementation after resetAllMocks
|
||||
const setMockInvokeBehavior = (overrides: MockInvokeOverrides = {}) => {
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
@@ -47,9 +50,37 @@ describe('useVaultSwitcher', () => {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(overrides.checkVaultExists ?? true)
|
||||
if (cmd === 'create_empty_vault' && overrides.createEmptyVault) {
|
||||
return Promise.resolve().then(() => overrides.createEmptyVault?.(args as { targetPath: string }))
|
||||
}
|
||||
if (cmd === 'create_getting_started_vault' && overrides.createGettingStartedVault) {
|
||||
return Promise.resolve().then(() => overrides.createGettingStartedVault?.(args as { targetPath: string }))
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
}
|
||||
|
||||
const renderLoadedVaultSwitcher = async () => {
|
||||
const hook = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.loaded).toBe(true)
|
||||
})
|
||||
return hook
|
||||
}
|
||||
|
||||
const setWorkVaultWithHiddenGettingStarted = () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
setMockInvokeBehavior()
|
||||
})
|
||||
|
||||
it('starts with default vaults', async () => {
|
||||
@@ -206,6 +237,42 @@ describe('useVaultSwitcher', () => {
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
|
||||
})
|
||||
|
||||
it('creates an empty vault and switches to it', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault')
|
||||
setMockInvokeBehavior({
|
||||
createEmptyVault: ({ targetPath }) => targetPath,
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_empty_vault', { targetPath: '/Users/luca/New Vault' })
|
||||
expect(result.current.vaultPath).toBe('/Users/luca/New Vault')
|
||||
expect(result.current.allVaults.some(v => v.path === '/Users/luca/New Vault')).toBe(true)
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "New Vault" created and opened')
|
||||
})
|
||||
|
||||
it('shows a friendly toast when empty-vault creation targets a non-empty folder', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Busy Folder')
|
||||
setMockInvokeBehavior({
|
||||
createEmptyVault: () => Promise.reject('Choose an empty folder to create a new vault'),
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
|
||||
expect(onToast).toHaveBeenCalledWith('Choose an empty folder to create a new vault')
|
||||
})
|
||||
|
||||
describe('removeVault', () => {
|
||||
it('removes an extra vault from the list', async () => {
|
||||
mockVaultListStore = {
|
||||
@@ -304,11 +371,7 @@ describe('useVaultSwitcher', () => {
|
||||
|
||||
describe('restoreGettingStarted', () => {
|
||||
it('un-hides the Getting Started vault', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
@@ -324,11 +387,7 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('switches to the Getting Started vault after restoring', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
@@ -342,25 +401,13 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('attempts to create vault on disk if it does not exist', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'create_getting_started_vault') return Promise.resolve(expectedDefaultVaultPath)
|
||||
return Promise.resolve(null)
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: false,
|
||||
createGettingStartedVault: ({ targetPath }) => targetPath,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
@@ -371,27 +418,13 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'create_getting_started_vault') {
|
||||
return Promise.reject('git clone failed: fatal: unable to access')
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: false,
|
||||
createGettingStartedVault: () => Promise.reject('git clone failed: fatal: unable to access'),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
|
||||
@@ -359,6 +359,21 @@ function formatGettingStartedRestoreError(err: unknown): string {
|
||||
return `Could not prepare Getting Started vault: ${message}`
|
||||
}
|
||||
|
||||
function formatCreateEmptyVaultError(err: unknown): string {
|
||||
const message =
|
||||
typeof err === 'string'
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `${err}`
|
||||
|
||||
if (message.includes('Choose an empty folder')) {
|
||||
return message
|
||||
}
|
||||
|
||||
return `Could not create empty vault: ${message}`
|
||||
}
|
||||
|
||||
async function ensureGettingStartedVaultReady(path: string): Promise<void> {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path })
|
||||
if (!exists) {
|
||||
@@ -505,6 +520,25 @@ function useOpenLocalFolderAction(
|
||||
}, [addAndSwitch, onToastRef])
|
||||
}
|
||||
|
||||
function useCreateEmptyVaultAction(
|
||||
addAndSwitch: (path: string, label: string) => void,
|
||||
onToastRef: MutableRefObject<(msg: string) => void>,
|
||||
) {
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
const targetPath = await pickFolder('Choose where to create your vault')
|
||||
if (!targetPath) return
|
||||
|
||||
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
|
||||
const label = labelFromPath({ path: vaultPath })
|
||||
addAndSwitch(vaultPath, label)
|
||||
onToastRef.current(`Vault "${label}" created and opened`)
|
||||
} catch (err) {
|
||||
onToastRef.current(formatCreateEmptyVaultError(err))
|
||||
}
|
||||
}, [addAndSwitch, onToastRef])
|
||||
}
|
||||
|
||||
function useRemoveVaultAction({
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
@@ -589,6 +623,7 @@ function useVaultActions({
|
||||
}, [addVault, switchVault])
|
||||
|
||||
return {
|
||||
handleCreateEmptyVault: useCreateEmptyVaultAction(addAndSwitch, onToastRef),
|
||||
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
|
||||
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
|
||||
removeVault: useRemoveVaultAction({
|
||||
@@ -656,7 +691,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
hiddenDefaults,
|
||||
extraVaults,
|
||||
)
|
||||
const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
|
||||
const { handleCreateEmptyVault, handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
|
||||
...persistedState,
|
||||
allVaults,
|
||||
defaultVaults,
|
||||
@@ -668,6 +703,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
return {
|
||||
allVaults,
|
||||
defaultPath,
|
||||
handleCreateEmptyVault,
|
||||
handleOpenLocalFolder,
|
||||
handleVaultCloned,
|
||||
isGettingStartedHidden,
|
||||
|
||||
@@ -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',
|
||||
|
||||
195
tests/smoke/create-empty-vault-flows.spec.ts
Normal file
195
tests/smoke/create-empty-vault-flows.spec.ts
Normal 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 })
|
||||
})
|
||||
Reference in New Issue
Block a user