Merge pull request #18 from refactoringhq/task/vault-picker-local

fix: vault picker — aggiungi opzione crea vault locale
This commit is contained in:
Luca Rossi
2026-02-23 21:02:02 +01:00
committed by GitHub
15 changed files with 14936 additions and 44 deletions

View File

@@ -1,19 +1,3 @@
## editor-save-bug — Done
### What was changed
1. **Removed broken auto-save**: Deleted `useAutoSave` hook (debounced save) which was causing the editor to reload previous content on every keystroke.
2. **Added explicit Cmd+S save**: New `useSaveNote` + `useEditorSave` hooks. Editor onChange buffers content in a ref; Cmd+S persists to disk. Shows "Saved" toast on success, "Nothing to save" when clean.
3. **Fixed rename-before-save**: `handleRenameTab` now calls `savePendingForPath(path)` before executing the rename, ensuring the file on disk is up to date. No more "Failed to rename note" error.
### Architecture decisions
- **No auto-save by design**: Consistent with git-based UX. User explicitly saves with Cmd+S, then commits when ready.
- **Pending content tracked via ref** (not state): Avoids unnecessary re-renders on every keystroke. The ref holds `{ path, content }` set by Editor onChange, read by handleSave.
- **useEditorSave extracted from App**: Reduces App component cyclomatic complexity (CodeScene quality gate passed).
### Tests
- 471 unit tests passing (was 466 before)
- New: `useSaveNote.test.ts` (3 tests), `useEditorSave.test.ts` (5 tests)
- Updated: `App.test.tsx` (Cmd+S now shows "Nothing to save" when no pending content)
- E2E: 2 tests passing (editor loads + Cmd+S shortcut works)
- Coverage: 78.82% (above 70% threshold)
- CodeScene quality gates: passed
Task: vault-picker-local
Summary: Added local vault creation options to vault picker
Commits: 5

14536
design/vault-picker-local.pen Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
test.describe('Vault Picker Local Options', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5240')
await page.waitForSelector('text=notes', { timeout: 10000 })
})
test('vault menu shows local folder options', async ({ page }) => {
// Screenshot before opening menu
await page.screenshot({ path: 'test-results/vault-picker-before.png', fullPage: true })
// Click the vault button in the status bar to open the menu
const vaultButton = page.locator('[title="Switch vault"]')
await expect(vaultButton).toBeVisible()
await vaultButton.click()
// Wait for menu to appear
await page.waitForTimeout(300)
// Verify all three options are visible
await expect(page.locator('text=Open local folder')).toBeVisible()
await expect(page.locator('text=Create new vault')).toBeVisible()
await expect(page.locator('text=Connect GitHub repo')).toBeVisible()
// Screenshot with menu open showing all options
await page.screenshot({ path: 'test-results/vault-picker-menu-open.png', fullPage: true })
})
test('vault menu options have correct test IDs', async ({ page }) => {
const vaultButton = page.locator('[title="Switch vault"]')
await vaultButton.click()
await page.waitForTimeout(200)
await expect(page.locator('[data-testid="vault-menu-open-local"]')).toBeVisible()
await expect(page.locator('[data-testid="vault-menu-create-new"]')).toBeVisible()
await expect(page.locator('[data-testid="vault-menu-connect-github"]')).toBeVisible()
})
})

View File

@@ -33,6 +33,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.0",
"class-variance-authority": "^0.7.1",

10
pnpm-lock.yaml generated
View File

@@ -62,6 +62,9 @@ importers:
'@tauri-apps/api':
specifier: ^2.10.1
version: 2.10.1
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.6.0
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
@@ -1714,6 +1717,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
@@ -5087,6 +5093,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.0
'@tauri-apps/cli-win32-x64-msvc': 2.10.0
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.10.1

67
src-tauri/Cargo.lock generated
View File

@@ -731,6 +731,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
]
@@ -1941,6 +1943,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-process",
"tauri-plugin-updater",
@@ -3204,6 +3207,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4119,6 +4146,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"

View File

@@ -30,6 +30,7 @@ futures-util = "0.3"
base64 = "0.22"
regex = "1"
dirs = "5"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2.10.0"
tauri-plugin-process = "2.3.1"

View File

@@ -7,6 +7,7 @@
],
"permissions": [
"core:default",
"dialog:default",
"updater:default",
"process:default"
]

View File

@@ -134,6 +134,49 @@ fn clone_repo(url: String, token: String, local_path: String) -> Result<String,
github::clone_repo(&url, &token, &local_path)
}
#[tauri::command]
fn create_vault_dir(path: String) -> Result<(), String> {
std::fs::create_dir_all(&path).map_err(|e| format!("Failed to create directory: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_vault_dir_creates_directory() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("my-new-vault");
assert!(!vault_path.exists());
let result = create_vault_dir(vault_path.to_string_lossy().to_string());
assert!(result.is_ok());
assert!(vault_path.is_dir());
}
#[test]
fn test_create_vault_dir_nested_path() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("deep/nested/vault");
assert!(!vault_path.exists());
let result = create_vault_dir(vault_path.to_string_lossy().to_string());
assert!(result.is_ok());
assert!(vault_path.is_dir());
}
#[test]
fn test_create_vault_dir_existing_dir_ok() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("existing");
std::fs::create_dir(&vault_path).unwrap();
// Calling create_vault_dir on an existing dir should succeed
let result = create_vault_dir(vault_path.to_string_lossy().to_string());
assert!(result.is_ok());
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -146,6 +189,8 @@ pub fn run() {
)?;
}
app.handle().plugin(tauri_plugin_dialog::init())?;
#[cfg(desktop)]
{
app.handle()
@@ -204,7 +249,8 @@ pub fn run() {
save_settings,
github_list_repos,
github_create_repo,
clone_repo
clone_repo,
create_vault_dir
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -17,7 +17,13 @@ import { useEditorSave } from './hooks/useEditorSave'
import { useAppKeyboard } from './hooks/useAppKeyboard'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { isTauri } from './mock-tauri'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import { pickFolder } from './utils/vault-dialog'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'
import { useUpdater } from './hooks/useUpdater'
import { UpdateBanner } from './components/UpdateBanner'
@@ -118,6 +124,43 @@ function App() {
setToastMessage(`Vault "${label}" cloned and opened`)
}, [handleSwitchVault])
const addAndSwitchVault = useCallback((path: string, label: string) => {
setExtraVaults(prev => {
if (prev.some(v => v.path === path)) return prev
return [...prev, { label, path }]
})
handleSwitchVault(path)
}, [handleSwitchVault])
const handleOpenLocalFolder = useCallback(async () => {
try {
const path = await pickFolder('Open vault folder')
if (!path) return
const label = path.split('/').pop() || 'Local Vault'
addAndSwitchVault(path, label)
setToastMessage(`Vault "${label}" opened`)
} catch (err) {
console.error('Failed to open local folder:', err)
setToastMessage(`Failed to open folder: ${err}`)
}
}, [addAndSwitchVault])
const handleCreateNewVault = useCallback(async () => {
try {
const name = prompt('New vault name:')
if (!name) return
const parentDir = await pickFolder('Choose location for new vault')
if (!parentDir) return
const fullPath = `${parentDir}/${name}`
await tauriCall('create_vault_dir', { path: fullPath })
addAndSwitchVault(fullPath, name)
setToastMessage(`Vault "${name}" created`)
} catch (err) {
console.error('Failed to create vault:', err)
setToastMessage(`Failed to create vault: ${err}`)
}
}, [addAndSwitchVault])
useEffect(() => {
if (!notes.activeTabPath) { setGitHistory([]); return }
vault.loadGitHistory(notes.activeTabPath).then(setGitHistory)
@@ -228,7 +271,7 @@ function App() {
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} />
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onCreateNewVault={handleCreateNewVault} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={() => setShowQuickOpen(false)} />
<CreateTypeDialog open={showCreateTypeDialog} onClose={() => setShowCreateTypeDialog(false)} onCreate={handleCreateType} />

View File

@@ -73,4 +73,68 @@ describe('StatusBar', () => {
fireEvent.click(vaultButton)
expect(screen.queryByText('Work Vault')).not.toBeInTheDocument()
})
it('shows "Open local folder" option in vault menu', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.getByText('Open local folder')).toBeInTheDocument()
})
it('calls onOpenLocalFolder when clicking "Open local folder"', () => {
const onOpenLocalFolder = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={onOpenLocalFolder} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByText('Open local folder'))
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
})
it('shows "Create new vault" option in vault menu', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateNewVault={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.getByText('Create new vault')).toBeInTheDocument()
})
it('calls onCreateNewVault when clicking "Create new vault"', () => {
const onCreateNewVault = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateNewVault={onCreateNewVault} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByText('Create new vault'))
expect(onCreateNewVault).toHaveBeenCalledOnce()
})
it('shows all three add-vault options together', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onOpenLocalFolder={vi.fn()}
onCreateNewVault={vi.fn()}
onConnectGitHub={vi.fn()}
/>
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.getByText('Open local folder')).toBeInTheDocument()
expect(screen.getByText('Create new vault')).toBeInTheDocument()
expect(screen.getByText('Connect GitHub repo')).toBeInTheDocument()
})
it('closes menu after clicking "Open local folder"', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByText('Open local folder'))
// Menu should close after clicking an action
expect(screen.queryByText('Open local folder')).not.toBeInTheDocument()
})
})

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github } from 'lucide-react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, FolderPlus } from 'lucide-react'
export interface VaultOption {
label: string
@@ -12,6 +12,8 @@ interface StatusBarProps {
vaults: VaultOption[]
onSwitchVault: (path: string) => void
onOpenSettings?: () => void
onOpenLocalFolder?: () => void
onCreateNewVault?: () => void
onConnectGitHub?: () => void
hasGitHub?: boolean
}
@@ -34,7 +36,7 @@ function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isAc
)
}
function VaultMenu({ vaults, vaultPath, onSwitchVault, onConnectGitHub, hasGitHub }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onConnectGitHub?: () => void; hasGitHub?: boolean }) {
function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onCreateNewVault, onConnectGitHub, hasGitHub }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onOpenLocalFolder?: () => void; onCreateNewVault?: () => void; onConnectGitHub?: () => void; hasGitHub?: boolean }) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const activeVault = vaults.find((v) => v.path === vaultPath)
@@ -57,25 +59,57 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onConnectGitHub, hasGitHu
{open && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} />)}
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
{onOpenLocalFolder && (
<div
role="button"
onClick={() => { onOpenLocalFolder(); setOpen(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
cursor: 'pointer', background: 'transparent',
color: 'var(--muted-foreground)', fontSize: 12,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="vault-menu-open-local"
>
<FolderOpen size={12} />
Open local folder
</div>
)}
{onCreateNewVault && (
<div
role="button"
onClick={() => { onCreateNewVault(); setOpen(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
cursor: 'pointer', background: 'transparent',
color: 'var(--muted-foreground)', fontSize: 12,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="vault-menu-create-new"
>
<FolderPlus size={12} />
Create new vault
</div>
)}
{onConnectGitHub && (
<>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<div
role="button"
onClick={() => { onConnectGitHub(); setOpen(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
cursor: 'pointer', background: 'transparent',
color: hasGitHub ? 'var(--muted-foreground)' : 'var(--accent-blue)', fontSize: 12,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="vault-menu-connect-github"
>
<Github size={12} />
Connect GitHub repo
</div>
</>
<div
role="button"
onClick={() => { onConnectGitHub(); setOpen(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
cursor: 'pointer', background: 'transparent',
color: hasGitHub ? 'var(--muted-foreground)' : 'var(--accent-blue)', fontSize: 12,
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="vault-menu-connect-github"
>
<Github size={12} />
Connect GitHub repo
</div>
)}
</div>
)}
@@ -87,11 +121,11 @@ const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
const SEP_STYLE = { color: 'var(--border)' } as const
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings, onConnectGitHub, hasGitHub }: StatusBarProps) {
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onCreateNewVault, onConnectGitHub, hasGitHub }: StatusBarProps) {
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onCreateNewVault={onCreateNewVault} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><Package size={13} />v0.4.2</span>
<span style={SEP_STYLE}>|</span>

View File

@@ -1760,6 +1760,7 @@ const mockHandlers: Record<string, (args: any) => any> = {
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
purge_trash: () => [],
migrate_is_a_to_type: () => 0,
create_vault_dir: () => null,
}
export function isTauri(): boolean {

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock isTauri — default to browser mode
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
}))
import { pickFolder } from './vault-dialog'
import { isTauri } from '../mock-tauri'
describe('pickFolder', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns user input from prompt in browser mode', async () => {
vi.mocked(isTauri).mockReturnValue(false)
vi.spyOn(window, 'prompt').mockReturnValue('/Users/test/my-vault')
const result = await pickFolder('Select vault')
expect(result).toBe('/Users/test/my-vault')
expect(window.prompt).toHaveBeenCalledWith('Select vault')
})
it('returns null when user cancels prompt in browser mode', async () => {
vi.mocked(isTauri).mockReturnValue(false)
vi.spyOn(window, 'prompt').mockReturnValue(null)
const result = await pickFolder('Select vault')
expect(result).toBeNull()
})
it('uses default title when none provided in browser mode', async () => {
vi.mocked(isTauri).mockReturnValue(false)
vi.spyOn(window, 'prompt').mockReturnValue('/some/path')
await pickFolder()
expect(window.prompt).toHaveBeenCalledWith('Enter folder path:')
})
})

25
src/utils/vault-dialog.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Vault dialog utilities.
* In Tauri mode, uses the native dialog plugin for folder picking.
* In browser mode, falls back to window.prompt() for testing.
*/
import { isTauri } from '../mock-tauri'
/**
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
* Returns the selected folder path, or null if the user cancelled.
*/
export async function pickFolder(title?: string): Promise<string | null> {
if (isTauri()) {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
directory: true,
multiple: false,
title: title ?? 'Select folder',
})
return selected as string | null
}
// Browser fallback: prompt for path
return prompt(title ?? 'Enter folder path:')
}