feat: add local vault options to vault picker

Add "Open local folder" and "Create new vault" options to the vault
picker dropdown. Uses tauri-plugin-dialog for native folder picker
in Tauri mode, falls back to prompt() in browser mode.

- Rust: add tauri-plugin-dialog, create_vault_dir command
- Frontend: pickFolder utility, StatusBar UI, App.tsx handlers
- Mock: create_vault_dir mock handler for browser testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 14:42:39 +01:00
parent d793093249
commit 74db63f1c8
10 changed files with 216 additions and 25 deletions

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,11 @@ 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_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -146,6 +151,8 @@ pub fn run() {
)?;
}
app.handle().plugin(tauri_plugin_dialog::init())?;
#[cfg(desktop)]
{
app.handle()
@@ -204,7 +211,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

@@ -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 {

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:')
}