From 74db63f1c87f441b9f3a61310a5b41fc9843050c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 14:42:39 +0100 Subject: [PATCH 1/5] 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 --- package.json | 1 + pnpm-lock.yaml | 10 ++++ src-tauri/Cargo.lock | 67 +++++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 10 +++- src/App.tsx | 47 ++++++++++++++++- src/components/StatusBar.tsx | 78 +++++++++++++++++++++-------- src/mock-tauri.ts | 1 + src/utils/vault-dialog.ts | 25 +++++++++ 10 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 src/utils/vault-dialog.ts diff --git a/package.json b/package.json index 26a95fd5..1350c283 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15428e3d..9e53e1ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 254e0497..181c32d6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e4641e0..153ae755 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5e95bf9c..25928428 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ ], "permissions": [ "core:default", + "dialog:default", "updater:default", "process:default" ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f25ee28..bfd524ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -134,6 +134,11 @@ fn clone_repo(url: String, token: String, local_path: String) -> Result 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"); diff --git a/src/App.tsx b/src/App.tsx index 3ccf38e0..a558637f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(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() { /> - setShowSettings(true)} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} /> + setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onCreateNewVault={handleCreateNewVault} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} /> setToastMessage(null)} /> setShowQuickOpen(false)} /> setShowCreateTypeDialog(false)} onCreate={handleCreateType} /> diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 9ada3e94..5a49aaa1 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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(null) const activeVault = vaults.find((v) => v.path === vaultPath) @@ -57,25 +59,57 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onConnectGitHub, hasGitHu {open && (
{vaults.map((v) => { onSwitchVault(v.path); setOpen(false) }} />)} +
+ {onOpenLocalFolder && ( +
{ 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" + > + + Open local folder +
+ )} + {onCreateNewVault && ( +
{ 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" + > + + Create new vault +
+ )} {onConnectGitHub && ( - <> -
-
{ 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" - > - - Connect GitHub repo -
- +
{ 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" + > + + Connect GitHub repo +
)}
)} @@ -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 (