From 9196dffc983bfdba103c19b4b32d2f2247468790 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 15:09:26 +0100 Subject: [PATCH 01/22] Add comprehensive menu system with File, Edit, View, and Help menus to custom title bar - Add File menu with New Database, Open Recent, Save, and Close Database options - Add Edit menu with Undo/Redo functionality (Ctrl+Z/Ctrl+Y shortcuts) - Add View menu with Toggle Search option - Add Help menu with About dialog - Implement undo/redo system using new useUndoRedo hook - Add OpenRecentDialog component for quick access to recent databases - Add recent database tracking via addRecentDatabase calls --- app/about/page.tsx | 7 + components/About.tsx | 78 ++++++++ components/AboutDialog.tsx | 89 +++++++++ components/CreateDatabaseDialog.tsx | 3 +- components/CustomTitleBar.tsx | 168 ++++++++++++++--- components/OpenRecentDialog.tsx | 96 ++++++++++ components/QuickUnlockScreen.tsx | 2 + components/UnlockScreen.tsx | 3 +- .../main-app/hooks/useKeyboardShortcuts.ts | 28 ++- components/main-app/hooks/useUndoRedo.ts | 77 ++++++++ components/main-app/index.tsx | 178 ++++++++++++++++-- lib/storage.ts | 32 ++++ lib/window.ts | 42 ++++- src-tauri/tauri.conf.json | 14 ++ 14 files changed, 771 insertions(+), 46 deletions(-) create mode 100644 app/about/page.tsx create mode 100644 components/About.tsx create mode 100644 components/AboutDialog.tsx create mode 100644 components/OpenRecentDialog.tsx create mode 100644 components/main-app/hooks/useUndoRedo.ts diff --git a/app/about/page.tsx b/app/about/page.tsx new file mode 100644 index 0000000..deec6a2 --- /dev/null +++ b/app/about/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { About } from "@/components/About"; + +export default function AboutPage() { + return ; +} diff --git a/components/About.tsx b/components/About.tsx new file mode 100644 index 0000000..1c5fb57 --- /dev/null +++ b/components/About.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Github, ExternalLink, Heart } from "lucide-react"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { CustomTitleBar } from "@/components/CustomTitleBar"; +import { open } from "@tauri-apps/plugin-shell"; + +export function About() { + const handleClose = async () => { + const window = getCurrentWebviewWindow(); + await window.close(); + }; + + return ( +
+ + + +
+ {/* App Icon and Title */} +
+ App Icon +
+

Simple Password Manager

+

Version 1.0.0

+
+
+ + {/* Description */} +

+ A secure and modern password manager built with the proven KeePass database format. + Keep your passwords safe with strong encryption. +

+ + {/* Links */} +
+ +
+ + {/* Tech Stack - Simple badges */} +
+ Tauri + React + Next.js + Rust + TypeScript +
+ + {/* Footer */} +
+

+ Made with by Jonas Laux +

+

+ Open Source • MIT License +

+
+
+
+
+ ); +} diff --git a/components/AboutDialog.tsx b/components/AboutDialog.tsx new file mode 100644 index 0000000..02fbebb --- /dev/null +++ b/components/AboutDialog.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ExternalLink, Github } from "lucide-react"; + +interface AboutDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AboutDialog({ open, onOpenChange }: AboutDialogProps) { + return ( + + + +
+ App Icon +
+ Simple Password Manager + + Version 1.0.0 + +
+
+
+ +
+

+ A secure, modern password manager built with KeePass database format (.kdbx). + Keep your passwords safe with strong encryption and a clean, intuitive interface. +

+ +
+

Features

+
    +
  • KeePass 2.x database format support
  • +
  • Strong encryption (AES-256, ChaCha20)
  • +
  • Password generator with strength meter
  • +
  • Breach checking with Have I Been Pwned
  • +
  • Auto-lock and quick unlock
  • +
  • Cross-platform support
  • +
+
+ +
+ + +
+ +
+

+ Built with Tauri, React, and Next.js +

+

+ © 2026 Simple Password Manager. Open Source Software. +

+
+
+
+
+ ); +} diff --git a/components/CreateDatabaseDialog.tsx b/components/CreateDatabaseDialog.tsx index 924b783..cfc087a 100644 --- a/components/CreateDatabaseDialog.tsx +++ b/components/CreateDatabaseDialog.tsx @@ -16,7 +16,7 @@ import { FolderOpen } from "lucide-react"; import { createDatabase } from "@/lib/tauri"; import { useToast } from "@/components/ui/use-toast"; import { open } from "@tauri-apps/plugin-dialog"; -import { saveLastDatabasePath } from "@/lib/storage"; +import { saveLastDatabasePath, addRecentDatabase } from "@/lib/storage"; import { PasswordStrengthMeter } from "@/components/PasswordStrengthMeter"; interface CreateDatabaseDialogProps { @@ -93,6 +93,7 @@ export function CreateDatabaseDialog({ await createDatabase(fullPath, password); saveLastDatabasePath(fullPath); + addRecentDatabase(fullPath); toast({ title: "Success", diff --git a/components/CustomTitleBar.tsx b/components/CustomTitleBar.tsx index 0b2f11d..3f2e57d 100644 --- a/components/CustomTitleBar.tsx +++ b/components/CustomTitleBar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Minus, Square, X, Copy, Save, LogOut } from "lucide-react"; +import { Minus, Square, X, Copy, Save, LogOut, Undo2, Redo2, Clipboard, ClipboardPaste, FolderOpen, Database as DatabaseIcon, Eye, EyeOff, Info, ExternalLink, FileText } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Settings } from "@/components/animate-ui/icons/settings"; import { Search } from "@/components/animate-ui/icons/search"; @@ -23,6 +23,17 @@ interface CustomTitleBarProps { onSave?: () => void; onLogout?: () => void; onToggleSearch?: () => void; + onUndo?: () => void; + onRedo?: () => void; + onCopy?: () => void; + onPaste?: () => void; + onOpenRecent?: () => void; + onNewDatabase?: () => void; + onTogglePasswords?: () => void; + onAbout?: () => void; + canUndo?: boolean; + canRedo?: boolean; + passwordsVisible?: boolean; } export function CustomTitleBar({ @@ -33,6 +44,17 @@ export function CustomTitleBar({ onSave, onLogout, onToggleSearch, + onUndo, + onRedo, + onCopy, + onPaste, + onOpenRecent, + onNewDatabase, + onTogglePasswords, + onAbout, + canUndo = false, + canRedo = false, + passwordsVisible = false, }: CustomTitleBarProps) { const [isMaximized, setIsMaximized] = useState(false); const [isSettingsHovered, setIsSettingsHovered] = useState(false); @@ -92,40 +114,126 @@ export function CustomTitleBar({ } }} > - {/* Left: Icon + Optional File Menu (only for main app) */} -
+ {/* Left: Icon + Optional Menus (only for main app) */} +
App Icon {showMenu && ( - - - - - - - - Save Database - Ctrl+S - - - - - Logout - - - + <> + {/* File Menu */} + + + + + + + + New Database + Ctrl+N + + + + Open Recent + + + + + Save Database + Ctrl+S + + + + + Close Database + Ctrl+W + + + + + {/* Edit Menu */} + + + + + + + + Undo + Ctrl+Z + + + + Redo + Ctrl+Y + + + + + {/* View Menu */} + + + + + + + + Toggle Search + Ctrl+F + + + + + {/* Help Menu */} + + + + + + + + About + + + + )}
diff --git a/components/OpenRecentDialog.tsx b/components/OpenRecentDialog.tsx new file mode 100644 index 0000000..460a4ac --- /dev/null +++ b/components/OpenRecentDialog.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Clock, Trash2, Database as DatabaseIcon } from "lucide-react"; +import { getRecentDatabases, clearRecentDatabase } from "@/lib/storage"; + +interface OpenRecentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectDatabase: (path: string) => void; +} + +export function OpenRecentDialog({ open, onOpenChange, onSelectDatabase }: OpenRecentDialogProps) { + const [recentDatabases, setRecentDatabases] = useState([]); + + useEffect(() => { + if (open) { + setRecentDatabases(getRecentDatabases()); + } + }, [open]); + + const handleRemove = (path: string, e: React.MouseEvent) => { + e.stopPropagation(); + clearRecentDatabase(path); + setRecentDatabases(getRecentDatabases()); + }; + + const handleSelect = (path: string) => { + onSelectDatabase(path); + onOpenChange(false); + }; + + const getFileName = (path: string) => { + const parts = path.split(/[/\\]/); + return parts[parts.length - 1]; + }; + + return ( + + + + + + Open Recent Database + + + Select a recently opened database to unlock it + + + +
+ {recentDatabases.length === 0 ? ( +
+ +

No recent databases

+
+ ) : ( +
+ {recentDatabases.map((path, index) => ( +
handleSelect(path)} + > +
+ +
+

{getFileName(path)}

+

{path}

+
+
+ +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/components/QuickUnlockScreen.tsx b/components/QuickUnlockScreen.tsx index 8d95a5d..b507335 100644 --- a/components/QuickUnlockScreen.tsx +++ b/components/QuickUnlockScreen.tsx @@ -10,6 +10,7 @@ import { openDatabase } from "@/lib/tauri"; import { useToast } from "@/components/ui/use-toast"; import { KdfWarningDialog } from "@/components/KdfWarningDialog"; import { CustomTitleBar } from "@/components/CustomTitleBar"; +import { addRecentDatabase } from "@/lib/storage"; import { invoke } from "@tauri-apps/api/core"; interface QuickUnlockScreenProps { @@ -42,6 +43,7 @@ export function QuickUnlockScreen({ setLoading(true); try { const [rootGroup, dbPath] = await openDatabase(lastDatabasePath, password); + addRecentDatabase(lastDatabasePath); // Check if KDF warning was dismissed for this database const dismissedDbs = JSON.parse(localStorage.getItem("kdf_warning_dismissed_dbs") || "[]"); diff --git a/components/UnlockScreen.tsx b/components/UnlockScreen.tsx index fb90a05..6813e75 100644 --- a/components/UnlockScreen.tsx +++ b/components/UnlockScreen.tsx @@ -12,7 +12,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { CreateDatabaseDialog } from "@/components/CreateDatabaseDialog"; import { KdfWarningDialog } from "@/components/KdfWarningDialog"; import { CustomTitleBar } from "@/components/CustomTitleBar"; -import { saveLastDatabasePath } from "@/lib/storage"; +import { saveLastDatabasePath, addRecentDatabase } from "@/lib/storage"; import { invoke } from "@tauri-apps/api/core"; import Image from "next/image"; @@ -75,6 +75,7 @@ export function UnlockScreen({ onUnlock, initialFilePath }: UnlockScreenProps) { try { const [rootGroup, dbPath] = await openDatabase(filePath, password); saveLastDatabasePath(filePath); + addRecentDatabase(filePath); // Check if KDF warning was dismissed for this database const dismissedDbs = JSON.parse(localStorage.getItem("kdf_warning_dismissed_dbs") || "[]"); diff --git a/components/main-app/hooks/useKeyboardShortcuts.ts b/components/main-app/hooks/useKeyboardShortcuts.ts index 3fa88a1..5f3562c 100644 --- a/components/main-app/hooks/useKeyboardShortcuts.ts +++ b/components/main-app/hooks/useKeyboardShortcuts.ts @@ -6,10 +6,14 @@ interface UseKeyboardShortcutsProps { onSave: () => void; onToggleSearch?: () => void; onCloseSearch?: () => void; + onUndo?: () => void; + onRedo?: () => void; + onClose?: () => void; + onNewDatabase?: () => void; isSearchVisible?: boolean; } -export function useKeyboardShortcuts({ onSave, onToggleSearch, onCloseSearch, isSearchVisible }: UseKeyboardShortcutsProps) { +export function useKeyboardShortcuts({ onSave, onToggleSearch, onCloseSearch, onUndo, onRedo, onClose, onNewDatabase, isSearchVisible }: UseKeyboardShortcutsProps) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl/Cmd+S: Save @@ -17,11 +21,31 @@ export function useKeyboardShortcuts({ onSave, onToggleSearch, onCloseSearch, is e.preventDefault(); onSave(); } + // Ctrl/Cmd+W: Close Database + if ((e.ctrlKey || e.metaKey) && e.key === 'w') { + e.preventDefault(); + onClose?.(); + } + // Ctrl/Cmd+N: New Database + if ((e.ctrlKey || e.metaKey) && e.key === 'n') { + e.preventDefault(); + onNewDatabase?.(); + } // Ctrl/Cmd+F: Open search if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); onToggleSearch?.(); } + // Ctrl/Cmd+Z: Undo + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + onUndo?.(); + } + // Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z: Redo + if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + e.preventDefault(); + onRedo?.(); + } // ESC: Close search if visible if (e.key === 'Escape' && isSearchVisible) { e.preventDefault(); @@ -31,5 +55,5 @@ export function useKeyboardShortcuts({ onSave, onToggleSearch, onCloseSearch, is window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [onSave, onToggleSearch, onCloseSearch, isSearchVisible]); + }, [onSave, onToggleSearch, onCloseSearch, onUndo, onRedo, onClose, onNewDatabase, isSearchVisible]); } diff --git a/components/main-app/hooks/useUndoRedo.ts b/components/main-app/hooks/useUndoRedo.ts new file mode 100644 index 0000000..fbb3f8c --- /dev/null +++ b/components/main-app/hooks/useUndoRedo.ts @@ -0,0 +1,77 @@ +import { useState, useCallback, useRef } from 'react'; + +interface HistoryState { + action: string; + timestamp: number; + undo: () => Promise; + redo: () => Promise; +} + +export function useUndoRedo() { + const [history, setHistory] = useState([]); + const [currentIndex, setCurrentIndex] = useState(-1); + const isUndoingRef = useRef(false); + + const addToHistory = useCallback((action: string, undo: () => Promise, redo: () => Promise) => { + if (isUndoingRef.current) return; + + const newState: HistoryState = { + action, + timestamp: Date.now(), + undo, + redo, + }; + + setHistory(prev => { + const newHistory = prev.slice(0, currentIndex + 1); + newHistory.push(newState); + return newHistory.slice(-50); + }); + + setCurrentIndex(prev => { + const newIndex = Math.min(prev + 1, 49); + return newIndex; + }); + }, [currentIndex]); + + const undo = useCallback(async () => { + if (currentIndex < 0) return; + + isUndoingRef.current = true; + try { + await history[currentIndex].undo(); + setCurrentIndex(prev => prev - 1); + } finally { + isUndoingRef.current = false; + } + }, [currentIndex, history]); + + const redo = useCallback(async () => { + if (currentIndex >= history.length - 1) return; + + isUndoingRef.current = true; + try { + await history[currentIndex + 1].redo(); + setCurrentIndex(prev => prev + 1); + } finally { + isUndoingRef.current = false; + } + }, [currentIndex, history]); + + const canUndo = currentIndex >= 0; + const canRedo = currentIndex < history.length - 1; + + const clear = useCallback(() => { + setHistory([]); + setCurrentIndex(-1); + }, []); + + return { + addToHistory, + undo, + redo, + canUndo, + canRedo, + clear, + }; +} diff --git a/components/main-app/index.tsx b/components/main-app/index.tsx index b5f991e..9c3ea84 100644 --- a/components/main-app/index.tsx +++ b/components/main-app/index.tsx @@ -8,7 +8,7 @@ import { EntryList } from "@/components/entry-list"; import { UnsavedChangesDialog } from "@/components/UnsavedChangesDialog"; import { DatabaseConflictDialog } from "@/components/DatabaseConflictDialog"; import { Dashboard } from "@/components/Dashboard"; -import { openEntryWindow, requestCloseAllChildWindows } from "@/lib/window"; +import { openEntryWindow, requestCloseAllChildWindows, openAboutWindow } from "@/lib/window"; import { useToast } from "@/components/ui/use-toast"; import type { GroupData, EntryData } from "@/lib/tauri"; import { loadGroupTreeState } from "@/lib/group-state"; @@ -34,12 +34,17 @@ import { findGroupByUuid, isDescendant } from "@/components/group-tree/utils"; import { CustomTitleBar } from "@/components/CustomTitleBar"; import { SearchHeader } from "@/components/SearchHeader"; +import { OpenRecentDialog } from "@/components/OpenRecentDialog"; +import { CreateDatabaseDialog } from "@/components/CreateDatabaseDialog"; import { useAutoLock } from "./hooks/useAutoLock"; import { useWindowManagement } from "./hooks/useWindowManagement"; import { useSearch } from "./hooks/useSearch"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useEntryEvents } from "./hooks/useEntryEvents"; +import { useUndoRedo } from "./hooks/useUndoRedo"; import { listen } from "@tauri-apps/api/event"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import { addRecentDatabase } from "@/lib/storage"; interface MainAppProps { onClose: (isManualLogout?: boolean) => void; @@ -88,7 +93,12 @@ export function MainApp({ onClose }: MainAppProps) { const [showConflictDialog, setShowConflictDialog] = useState(false); const [liveUpdatesEnabled, setLiveUpdatesEnabled] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false); + const [selectedEntryForCopy, setSelectedEntryForCopy] = useState(null); + const [passwordsVisible, setPasswordsVisible] = useState(false); + const [showOpenRecentDialog, setShowOpenRecentDialog] = useState(false); + const [showCreateDatabaseDialog, setShowCreateDatabaseDialog] = useState(false); const { toast } = useToast(); + const { addToHistory, undo, redo, canUndo, canRedo } = useUndoRedo(); const sensors = useSensors( useSensor(PointerSensor, { @@ -240,16 +250,123 @@ export function MainApp({ onClose }: MainAppProps) { setShowConflictDialog(false); }, []); - useKeyboardShortcuts({ - onSave: handleSave, - onToggleSearch: () => setIsSearchVisible(true), - onCloseSearch: () => { - setIsSearchVisible(false); - clearSearch(); - }, - isSearchVisible - }); - useEntryEvents(handleRefresh); + const handleCopyPassword = useCallback(async () => { + if (!selectedEntryForCopy) { + toast({ + title: "No Entry Selected", + description: "Please select an entry first", + variant: "destructive", + }); + return; + } + + try { + await writeText(selectedEntryForCopy.password); + toast({ + title: "Copied", + description: "Password copied to clipboard", + variant: "success", + }); + } catch (error: any) { + toast({ + title: "Error", + description: "Failed to copy password", + variant: "destructive", + }); + } + }, [selectedEntryForCopy, toast]); + + const handlePaste = useCallback(() => { + toast({ + title: "Paste", + description: "Paste functionality is context-dependent", + variant: "default", + }); + }, [toast]); + + const handleOpenRecent = useCallback(() => { + setShowOpenRecentDialog(true); + }, []); + + const handleSelectRecentDatabase = useCallback(async (path: string) => { + if (isDirty) { + toast({ + title: "Unsaved Changes", + description: "Please save or discard changes before opening another database", + variant: "destructive", + }); + return; + } + + await performClose(false); + window.location.reload(); + }, [isDirty, performClose, toast]); + + const handleNewDatabase = useCallback(() => { + if (isDirty) { + toast({ + title: "Unsaved Changes", + description: "Please save or discard changes before creating a new database", + variant: "destructive", + }); + return; + } + setShowCreateDatabaseDialog(true); + }, [isDirty, toast]); + + const handleNewDatabaseSuccess = useCallback(async () => { + await performClose(false); + window.location.reload(); + }, [performClose]); + + const handleTogglePasswords = useCallback(() => { + setPasswordsVisible(prev => !prev); + toast({ + title: passwordsVisible ? "Passwords Hidden" : "Passwords Visible", + description: passwordsVisible ? "Password columns are now hidden" : "Password columns are now visible", + variant: "default", + }); + }, [passwordsVisible, toast]); + + const handleUndo = useCallback(async () => { + try { + await undo(); + handleRefresh(); + toast({ + title: "Undo", + description: "Action undone", + variant: "success", + }); + } catch (error: any) { + toast({ + title: "Error", + description: "Failed to undo action", + variant: "destructive", + }); + } + }, [undo, handleRefresh, toast]); + + const handleRedo = useCallback(async () => { + try { + await redo(); + handleRefresh(); + toast({ + title: "Redo", + description: "Action redone", + variant: "success", + }); + } catch (error: any) { + toast({ + title: "Error", + description: "Failed to redo action", + variant: "destructive", + }); + } + }, [redo, handleRefresh, toast]); + + const handleAbout = useCallback(() => { + openAboutWindow(); + }, []); // Listen for HIBP setting changes from Settings window useEffect(() => { @@ -363,6 +480,22 @@ export function MainApp({ onClose }: MainAppProps) { } }; + // Keyboard shortcuts and entry events - must be after all handlers are defined + useKeyboardShortcuts({ + onSave: handleSave, + onClose: handleClose, + onNewDatabase: handleNewDatabase, + onToggleSearch: () => setIsSearchVisible(true), + onCloseSearch: () => { + setIsSearchVisible(false); + clearSearch(); + }, + onUndo: handleUndo, + onRedo: handleRedo, + isSearchVisible + }); + useEntryEvents(handleRefresh); + const handleUnsavedCancel = () => { setShowUnsavedDialog(false); setCloseAction(null); @@ -639,6 +772,17 @@ export function MainApp({ onClose }: MainAppProps) { onSave={handleSave} onLogout={handleClose} onToggleSearch={() => setIsSearchVisible(!isSearchVisible)} + onUndo={handleUndo} + onRedo={handleRedo} + onCopy={handleCopyPassword} + onPaste={handlePaste} + onOpenRecent={handleOpenRecent} + onNewDatabase={handleNewDatabase} + onTogglePasswords={handleTogglePasswords} + onAbout={handleAbout} + canUndo={canUndo} + canRedo={canRedo} + passwordsVisible={passwordsVisible} /> + + + + setShowCreateDatabaseDialog(false)} + onSuccess={handleNewDatabaseSuccess} + />
diff --git a/lib/storage.ts b/lib/storage.ts index 4e7feff..e7b2f80 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; const LAST_DATABASE_KEY = "lastDatabasePath"; +const RECENT_DATABASES_KEY = "recentDatabases"; const COLUMN_CONFIG_PREFIX = "columnConfig_"; const COLUMN_WIDTHS_PREFIX = "columnWidths_"; const HIBP_ENABLED_KEY = "hibpEnabled"; @@ -26,6 +27,37 @@ export function clearLastDatabasePath(): void { } } +export function addRecentDatabase(path: string): void { + if (typeof window !== "undefined") { + const recent = getRecentDatabases(); + const filtered = recent.filter(p => p !== path); + const updated = [path, ...filtered].slice(0, 10); + localStorage.setItem(RECENT_DATABASES_KEY, JSON.stringify(updated)); + } +} + +export function getRecentDatabases(): string[] { + if (typeof window !== "undefined") { + const stored = localStorage.getItem(RECENT_DATABASES_KEY); + if (stored) { + try { + return JSON.parse(stored); + } catch { + return []; + } + } + } + return []; +} + +export function clearRecentDatabase(path: string): void { + if (typeof window !== "undefined") { + const recent = getRecentDatabases(); + const filtered = recent.filter(p => p !== path); + localStorage.setItem(RECENT_DATABASES_KEY, JSON.stringify(filtered)); + } +} + // Column configuration per database export interface ColumnVisibility { title: boolean; diff --git a/lib/window.ts b/lib/window.ts index 2bfc7fc..45dc7e0 100644 --- a/lib/window.ts +++ b/lib/window.ts @@ -2,7 +2,7 @@ import { WebviewWindow, getAllWebviewWindows } from "@tauri-apps/api/webviewWind import type { EntryData } from "@/lib/tauri"; // Labels for child windows that should be closed when main app closes/logs out -const CHILD_WINDOW_PREFIXES = ['entry-', 'settings']; +const CHILD_WINDOW_PREFIXES = ['entry-', 'settings', 'about']; /** * Check if there are any open child windows (entry editors, settings) @@ -172,3 +172,43 @@ export async function openSettingsWindow() { throw error; } } + +export async function openAboutWindow() { + try { + const windowLabel = "about"; + + // Check if window already exists + const existingWindow = await WebviewWindow.getByLabel(windowLabel); + if (existingWindow) { + await existingWindow.setFocus(); + return; + } + + const isDev = process.env.NODE_ENV === 'development'; + const baseUrl = isDev ? 'http://localhost:3000' : window.location.origin; + + const webview = new WebviewWindow(windowLabel, { + url: `${baseUrl}/about`, + title: "About", + width: 600, + height: 700, + resizable: false, + maximizable: false, + decorations: false, + center: true, + }); + + console.log('Creating about window'); + + webview.once("tauri://created", () => { + console.log("About window created successfully"); + }); + + webview.once("tauri://error", (e) => { + console.error("Error creating about window:", e); + }); + } catch (error) { + console.error("Failed to open about window:", error); + throw error; + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7717f9a..6c0b0c9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -89,6 +89,20 @@ "core:event:allow-emit", "dialog:allow-ask" ] + }, + { + "identifier": "about-capability", + "windows": ["about"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-destroy", + "core:window:allow-minimize", + "core:window:allow-is-maximized", + "core:window:allow-start-dragging", + "core:event:allow-listen", + "shell:allow-open" + ] } ] } From 5ee426d8fb46daabe702b508139ecbf2efe4cb12 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 15:19:21 +0100 Subject: [PATCH 02/22] Add undo/redo tracking for entry and group operations - Add addToHistory prop to EntryList, EntryListItem, and GroupTree components - Implement undo/redo tracking for entry creation, deletion, and favorite toggle operations - Implement undo/redo tracking for group creation, rename, and move operations - Implement undo/redo tracking for entry move operations between groups - Add findGroupByName utility function to locate groups by name and parent UUID - Set global __addToHistory on window object --- components/entry-list/EntryListItem.tsx | 34 ++++++++++++-- components/entry-list/index.tsx | 36 +++++++++++++++ components/entry-list/types.ts | 1 + components/group-tree/index.tsx | 60 +++++++++++++++++++++++-- components/group-tree/types.ts | 1 + components/group-tree/utils.ts | 15 +++++++ components/main-app/index.tsx | 53 ++++++++++++++++++++-- 7 files changed, 189 insertions(+), 11 deletions(-) diff --git a/components/entry-list/EntryListItem.tsx b/components/entry-list/EntryListItem.tsx index 5efbbcc..8045ca2 100644 --- a/components/entry-list/EntryListItem.tsx +++ b/components/entry-list/EntryListItem.tsx @@ -28,6 +28,7 @@ interface EntryListItemProps { onContextMenuChange: (open: boolean) => void; onCopyField: (text: string, fieldName: string) => void; onOpenUrl: (url: string) => void; + addToHistory?: (action: string, undo: () => Promise, redo: () => Promise) => void; onDuplicate: () => void; onDelete: () => void; onRefresh: () => void; @@ -67,13 +68,38 @@ export function EntryListItem({ const handleToggleFavorite = async (e: React.MouseEvent) => { e.stopPropagation(); try { - await updateEntry({ + const wasFavorite = entry.is_favorite; + const updatedEntry = { ...entry, - is_favorite: !entry.is_favorite, - }); + is_favorite: !wasFavorite, + }; + + await updateEntry(updatedEntry); + + // Track for undo/redo + if (onRefresh) { + const entryUuid = entry.uuid; + const originalState = wasFavorite; + const newState = !wasFavorite; + + if ((window as any).__addToHistory) { + (window as any).__addToHistory( + `${newState ? 'Add' : 'Remove'} "${entry.title}" ${newState ? 'to' : 'from'} favorites`, + async () => { + await updateEntry({ ...entry, is_favorite: originalState }); + onRefresh(); + }, + async () => { + await updateEntry({ ...entry, is_favorite: newState }); + onRefresh(); + } + ); + } + } + onRefresh(); toast({ - title: entry.is_favorite ? "Removed from favorites" : "Added to favorites", + title: wasFavorite ? "Removed from favorites" : "Added to favorites", variant: "success", }); } catch (error: any) { diff --git a/components/entry-list/index.tsx b/components/entry-list/index.tsx index 295c9a2..91a4b8b 100644 --- a/components/entry-list/index.tsx +++ b/components/entry-list/index.tsx @@ -40,6 +40,7 @@ export function EntryList({ rootGroupUuid, selectedGroupName, databasePath, + addToHistory, }: EntryListProps) { const [entries, setEntries] = useState([]); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -197,6 +198,22 @@ export function EntryList({ await createEntry(newEntry); + // Track for undo/redo + if (addToHistory) { + const entryDataCapture = { ...newEntry }; + addToHistory( + `Create entry "${newEntry.title}"`, + async () => { + await deleteEntry(entryDataCapture.uuid); + onRefresh(); + }, + async () => { + await createEntry(entryDataCapture); + onRefresh(); + } + ); + } + toast({ title: "Success", description: isFavoritesView @@ -268,7 +285,26 @@ export function EntryList({ if (!shouldDelete) return; try { + // Capture full entry data before deletion + const entryDataCapture = { ...entry }; + await deleteEntry(entry.uuid); + + // Track for undo/redo + if (addToHistory) { + addToHistory( + `Delete entry "${entryDataCapture.title}"`, + async () => { + await createEntry(entryDataCapture); + onRefresh(); + }, + async () => { + await deleteEntry(entryDataCapture.uuid); + onRefresh(); + } + ); + } + toast({ title: "Success", description: "Entry deleted successfully", diff --git a/components/entry-list/types.ts b/components/entry-list/types.ts index 423c1cb..1ab9502 100644 --- a/components/entry-list/types.ts +++ b/components/entry-list/types.ts @@ -38,6 +38,7 @@ export interface EntryListProps { rootGroupUuid?: string; selectedGroupName?: string; databasePath?: string; + addToHistory?: (action: string, undo: () => Promise, redo: () => Promise) => void; } export type { EntryData }; diff --git a/components/group-tree/index.tsx b/components/group-tree/index.tsx index fce0b9e..15e93c6 100644 --- a/components/group-tree/index.tsx +++ b/components/group-tree/index.tsx @@ -19,7 +19,7 @@ import { saveGroupTreeState } from "@/lib/group-state"; import { DraggableFolder } from "./DraggableFolder"; import { CreateGroupDialog, RenameGroupDialog } from "./GroupDialogs"; -import { findGroupByUuid, isDescendant } from "./utils"; +import { findGroupByUuid, findGroupByName, isDescendant } from "./utils"; import type { GroupTreeProps } from "./types"; export function GroupTree({ @@ -33,6 +33,7 @@ export function GroupTree({ activeId, overId, activeType, + addToHistory, }: GroupTreeProps) { const [expandedGroups, setExpandedGroups] = useState>( initialExpandedGroups || new Set([group.uuid]) @@ -66,7 +67,33 @@ export function GroupTree({ if (!newGroupName.trim()) return; try { - await createGroup(newGroupName, parentUuid, newGroupIconId); + // Generate UUID for tracking + const groupUuid = crypto.randomUUID(); + const groupName = newGroupName; + const groupIconId = newGroupIconId; + const groupParentUuid = parentUuid; + + await createGroup(groupName, groupParentUuid, groupIconId); + + // Track for undo/redo - note: we can't get the exact UUID from createGroup, so undo might be limited + if (addToHistory) { + addToHistory( + `Create group "${groupName}"`, + async () => { + // Undo: find and delete the created group by name + const groupToDelete = findGroupByName(group, groupName, groupParentUuid); + if (groupToDelete) { + await deleteGroup(groupToDelete.uuid); + onRefresh(); + } + }, + async () => { + await createGroup(groupName, groupParentUuid, groupIconId); + onRefresh(); + } + ); + } + toast({ title: "Success", description: "Group created successfully", variant: "success" }); setNewGroupName(""); setNewGroupIconId(48); @@ -85,7 +112,30 @@ export function GroupTree({ if (!renameGroupName.trim()) return; try { - await renameGroup(renameGroupUuid, renameGroupName, renameGroupIconId); + const groupUuid = renameGroupUuid; + const oldGroup = findGroupByUuid(group, groupUuid); + const oldName = oldGroup?.name || ""; + const oldIconId = oldGroup?.icon_id ?? 48; + const newName = renameGroupName; + const newIconId = renameGroupIconId; + + await renameGroup(groupUuid, newName, newIconId); + + // Track for undo/redo + if (addToHistory) { + addToHistory( + `Rename group "${oldName}" to "${newName}"`, + async () => { + await renameGroup(groupUuid, oldName, oldIconId); + onRefresh(); + }, + async () => { + await renameGroup(groupUuid, newName, newIconId); + onRefresh(); + } + ); + } + toast({ title: "Success", description: "Group renamed successfully", variant: "success" }); setShowRenameDialog(false); setRenameGroupName(""); @@ -105,13 +155,15 @@ export function GroupTree({ const groupName = groupToDelete?.name || "this group"; const shouldDelete = await ask( - `Are you sure you want to delete "${groupName}" and all its contents?`, + `Are you sure you want to delete "${groupName}" and all its contents?\n\nThis action cannot be undone.`, { kind: "warning", title: "Delete Group" } ); if (!shouldDelete) return; try { + // Note: We can't truly undo group deletion as it would need to recreate all entries + // This is a destructive operation that shouldn't be undoable await deleteGroup(uuid); toast({ title: "Success", description: "Group deleted successfully", variant: "success" }); if (onGroupDeleted) onGroupDeleted(uuid); diff --git a/components/group-tree/types.ts b/components/group-tree/types.ts index a9dbef3..9b401f1 100644 --- a/components/group-tree/types.ts +++ b/components/group-tree/types.ts @@ -12,6 +12,7 @@ export interface GroupTreeProps { activeId?: string | null; overId?: string | null; activeType?: 'folder' | 'entry' | null; + addToHistory?: (action: string, undo: () => Promise, redo: () => Promise) => void; } export interface DraggableFolderProps { diff --git a/components/group-tree/utils.ts b/components/group-tree/utils.ts index 106f06e..77b17eb 100644 --- a/components/group-tree/utils.ts +++ b/components/group-tree/utils.ts @@ -9,6 +9,21 @@ export const findGroupByUuid = (g: GroupData, uuid: string): GroupData | null => return null; }; +export const findGroupByName = (g: GroupData, name: string, parentUuid: string | null): GroupData | null => { + // Search in direct children of the parent + if (g.uuid === parentUuid || parentUuid === null) { + for (const child of g.children) { + if (child.name === name) return child; + } + } + // Recursively search in children + for (const child of g.children) { + const found = findGroupByName(child, name, parentUuid); + if (found) return found; + } + return null; +}; + export const findParentGroup = (g: GroupData, childUuid: string): GroupData | null => { for (const child of g.children) { if (child.uuid === childUuid) return g; diff --git a/components/main-app/index.tsx b/components/main-app/index.tsx index 9c3ea84..da45577 100644 --- a/components/main-app/index.tsx +++ b/components/main-app/index.tsx @@ -30,7 +30,7 @@ import { } from "@dnd-kit/core"; import { getIconComponent } from "@/components/IconPicker"; import { moveGroup } from "@/lib/tauri"; -import { findGroupByUuid, isDescendant } from "@/components/group-tree/utils"; +import { findGroupByUuid, findParentGroup, isDescendant } from "@/components/group-tree/utils"; import { CustomTitleBar } from "@/components/CustomTitleBar"; import { SearchHeader } from "@/components/SearchHeader"; @@ -169,6 +169,14 @@ export function MainApp({ onClose }: MainAppProps) { // Custom hooks const { searchQuery, searchResults, isSearching, searchScope, setSearchScope, handleSearch, clearSearch, refreshSearch, setIsSearching } = useSearch(); + // Set global addToHistory for components that need it (e.g., EntryListItem favorite toggle) + useEffect(() => { + (window as any).__addToHistory = addToHistory; + return () => { + delete (window as any).__addToHistory; + }; + }, [addToHistory]); + useAutoLock(performClose); const { windowTitle } = useWindowManagement({ @@ -652,13 +660,29 @@ export function MainApp({ onClose }: MainAppProps) { } try { + const oldGroupUuid = entry.group_uuid; await moveEntry(entry.uuid, targetId); + + // Track for undo/redo + addToHistory( + `Move entry "${entry.title}" to group`, + async () => { + await moveEntry(entry.uuid, oldGroupUuid); + await handleRefresh(); + }, + async () => { + await moveEntry(entry.uuid, targetId); + await handleRefresh(); + } + ); + + setIsDirty(true); + await handleRefresh(); toast({ title: "Success", description: `Moved "${entry.title}" to "${targetGroup.name}"`, variant: "success", }); - handleRefresh(); } catch (error: any) { toast({ title: "Error", @@ -687,13 +711,34 @@ export function MainApp({ onClose }: MainAppProps) { return; } + const oldParent = findParentGroup(rootGroup, draggedId); + const oldParentUuid = oldParent?.uuid || rootGroup.uuid; + await moveGroup(draggedId, targetId); + + // Track for undo/redo + const movedGroup = findGroupByUuid(rootGroup, draggedId); + if (movedGroup) { + addToHistory( + `Move group "${movedGroup.name}"`, + async () => { + await moveGroup(draggedId, oldParentUuid); + await handleRefresh(); + }, + async () => { + await moveGroup(draggedId, targetId); + await handleRefresh(); + } + ); + } + + setIsDirty(true); + await handleRefresh(); toast({ title: "Success", description: `Moved "${draggedGroup.name}" into "${targetGroup.name}"`, variant: "success", }); - handleRefresh(); } catch (error: any) { toast({ title: "Error", @@ -824,6 +869,7 @@ export function MainApp({ onClose }: MainAppProps) { activeId={dndActiveId} overId={dndOverId} activeType={dndActiveType} + addToHistory={addToHistory} /> )} @@ -863,6 +909,7 @@ export function MainApp({ onClose }: MainAppProps) { : undefined } databasePath={dbPath} + addToHistory={addToHistory} /> )} From 6160d591e2a0b8ff13c8b9b08dc31693a472736e Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 18:38:35 +0100 Subject: [PATCH 03/22] Replace OpenRecentDialog with inline submenu in File menu for recent databases - Remove OpenRecentDialog component and related state/handlers from MainApp - Add DropdownMenuSub components to CustomTitleBar for inline recent databases submenu - Load recent databases on mount using getRecentDatabases and store in local state - Implement openDatabaseInNewInstance function to open databases in new app instances via shell - Display recent database filenames with truncation and full path tooltips --- components/CustomTitleBar.tsx | 56 +++++++++++++++++++++++++++++------ components/main-app/index.tsx | 26 ---------------- src-tauri/tauri.conf.json | 4 ++- 3 files changed, 50 insertions(+), 36 deletions(-) diff --git a/components/CustomTitleBar.tsx b/components/CustomTitleBar.tsx index 3f2e57d..90660df 100644 --- a/components/CustomTitleBar.tsx +++ b/components/CustomTitleBar.tsx @@ -12,8 +12,13 @@ import { DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, } from "@/components/ui/dropdown-menu"; import { openSettingsWindow } from "@/lib/window"; +import { getRecentDatabases } from "@/lib/storage"; +import { open } from "@tauri-apps/plugin-shell"; interface CustomTitleBarProps { title?: string; @@ -27,7 +32,6 @@ interface CustomTitleBarProps { onRedo?: () => void; onCopy?: () => void; onPaste?: () => void; - onOpenRecent?: () => void; onNewDatabase?: () => void; onTogglePasswords?: () => void; onAbout?: () => void; @@ -48,7 +52,6 @@ export function CustomTitleBar({ onRedo, onCopy, onPaste, - onOpenRecent, onNewDatabase, onTogglePasswords, onAbout, @@ -59,6 +62,22 @@ export function CustomTitleBar({ const [isMaximized, setIsMaximized] = useState(false); const [isSettingsHovered, setIsSettingsHovered] = useState(false); const [isSearchHovered, setIsSearchHovered] = useState(false); + const [recentDatabases, setRecentDatabases] = useState([]); + + useEffect(() => { + setRecentDatabases(getRecentDatabases()); + }, []); + + const openDatabaseInNewInstance = async (dbPath: string) => { + try { + // Open the database file with the system's default handler + // Since .kdbx files are associated with this app, the OS will open a new instance + await open(dbPath); + } catch (error) { + console.error('Failed to open database in new instance:', error); + alert(`Could not open database: ${error}`); + } + }; useEffect(() => { const checkMaximized = async () => { @@ -139,13 +158,32 @@ export function CustomTitleBar({ New Database Ctrl+N - - - Open Recent - + + + + Open Recent + + + {recentDatabases.length > 0 ? ( + recentDatabases.map((dbPath) => ( + openDatabaseInNewInstance(dbPath)} + className="gap-2 cursor-pointer" + > + + + {dbPath.split('/').pop() || dbPath.split('\\').pop() || dbPath} + + + )) + ) : ( + + No recent databases + + )} + + (null); const [passwordsVisible, setPasswordsVisible] = useState(false); - const [showOpenRecentDialog, setShowOpenRecentDialog] = useState(false); const [showCreateDatabaseDialog, setShowCreateDatabaseDialog] = useState(false); const { toast } = useToast(); const { addToHistory, undo, redo, canUndo, canRedo } = useUndoRedo(); @@ -292,23 +290,6 @@ export function MainApp({ onClose }: MainAppProps) { }); }, [toast]); - const handleOpenRecent = useCallback(() => { - setShowOpenRecentDialog(true); - }, []); - - const handleSelectRecentDatabase = useCallback(async (path: string) => { - if (isDirty) { - toast({ - title: "Unsaved Changes", - description: "Please save or discard changes before opening another database", - variant: "destructive", - }); - return; - } - - await performClose(false); - window.location.reload(); - }, [isDirty, performClose, toast]); const handleNewDatabase = useCallback(() => { if (isDirty) { @@ -821,7 +802,6 @@ export function MainApp({ onClose }: MainAppProps) { onRedo={handleRedo} onCopy={handleCopyPassword} onPaste={handlePaste} - onOpenRecent={handleOpenRecent} onNewDatabase={handleNewDatabase} onTogglePasswords={handleTogglePasswords} onAbout={handleAbout} @@ -930,12 +910,6 @@ export function MainApp({ onClose }: MainAppProps) { onCancel={handleConflictCancel} /> - - setShowCreateDatabaseDialog(false)} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6c0b0c9..170e453 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -57,7 +57,9 @@ "fs:allow-read-dir", "clipboard-manager:allow-write-text", "clipboard-manager:allow-clear", - "shell:allow-open" + "shell:allow-open", + "shell:allow-spawn", + "shell:allow-execute" ] }, { From 0bd8d6b0e3063991ef027e5250ca5ff722e491ad Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:00:24 +0100 Subject: [PATCH 04/22] Add data-tauri-drag-region attributes to title bar elements and implement toggle behavior for search - Add data-tauri-drag-region to app icon, title text, right controls container, and menu separator - Simplify database path splitting regex to handle both forward and backslashes - Change onToggleSearch to toggle search visibility instead of only opening - Clear search when toggling off via onToggleSearch handler --- components/CustomTitleBar.tsx | 9 +++++---- components/main-app/index.tsx | 9 ++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/components/CustomTitleBar.tsx b/components/CustomTitleBar.tsx index 90660df..85de07b 100644 --- a/components/CustomTitleBar.tsx +++ b/components/CustomTitleBar.tsx @@ -139,6 +139,7 @@ export function CustomTitleBar({ src="/app-icon.png" alt="App Icon" className="h-4 w-4 ml-1" + data-tauri-drag-region /> {showMenu && ( <> @@ -173,7 +174,7 @@ export function CustomTitleBar({ > - {dbPath.split('/').pop() || dbPath.split('\\').pop() || dbPath} + {dbPath.split(/[/\\]/).pop() || dbPath} )) @@ -277,13 +278,13 @@ export function CustomTitleBar({ {/* Center: Title */}
- + {title}
{/* Right: Optional Search + Settings + Window Controls */} -
+
{showMenu && onToggleSearch && ( -
- ))} -
- )} - - - - ); -} +/** + * OpenRecentDialog component has been removed. + * + * Recent databases are now handled via the CustomTitleBar submenu. + * This file is intentionally left as a stub to document the removal + * and to avoid re-introducing an unused dialog component. + */ From a7734c25973e850dd1fda30ef583f9ff91d45e41 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:46:58 +0100 Subject: [PATCH 07/22] Update components/main-app/hooks/useKeyboardShortcuts.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/main-app/hooks/useKeyboardShortcuts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/main-app/hooks/useKeyboardShortcuts.ts b/components/main-app/hooks/useKeyboardShortcuts.ts index 5f3562c..45a195f 100644 --- a/components/main-app/hooks/useKeyboardShortcuts.ts +++ b/components/main-app/hooks/useKeyboardShortcuts.ts @@ -21,8 +21,8 @@ export function useKeyboardShortcuts({ onSave, onToggleSearch, onCloseSearch, on e.preventDefault(); onSave(); } - // Ctrl/Cmd+W: Close Database - if ((e.ctrlKey || e.metaKey) && e.key === 'w') { + // Ctrl/Cmd+Alt+W: Close Database + if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === 'w') { e.preventDefault(); onClose?.(); } From 462f0e2e8ad77e1d289acd8457557ed5463dc995 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:47:17 +0100 Subject: [PATCH 08/22] Update components/About.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/About.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/About.tsx b/components/About.tsx index 1c5fb57..63f8437 100644 --- a/components/About.tsx +++ b/components/About.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Github, ExternalLink, Heart } from "lucide-react"; +import { Github, Heart } from "lucide-react"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { CustomTitleBar } from "@/components/CustomTitleBar"; import { open } from "@tauri-apps/plugin-shell"; From 35cef5ce90db5f96b5cde536d44ed624e479540d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:48:30 +0000 Subject: [PATCH 09/22] Initial plan From 31ee6f02eaf7d47c2b28fc3adebe9ac6e136a522 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:48:49 +0100 Subject: [PATCH 10/22] Update components/group-tree/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/group-tree/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/group-tree/index.tsx b/components/group-tree/index.tsx index 15e93c6..3da8509 100644 --- a/components/group-tree/index.tsx +++ b/components/group-tree/index.tsx @@ -19,7 +19,7 @@ import { saveGroupTreeState } from "@/lib/group-state"; import { DraggableFolder } from "./DraggableFolder"; import { CreateGroupDialog, RenameGroupDialog } from "./GroupDialogs"; -import { findGroupByUuid, findGroupByName, isDescendant } from "./utils"; +import { findGroupByUuid, findGroupByName } from "./utils"; import type { GroupTreeProps } from "./types"; export function GroupTree({ From aedc1b89511f18b5dc23f609e45a9ef80e5eda17 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:49:25 +0100 Subject: [PATCH 11/22] Update components/main-app/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/main-app/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/main-app/index.tsx b/components/main-app/index.tsx index 004dc39..1afae40 100644 --- a/components/main-app/index.tsx +++ b/components/main-app/index.tsx @@ -43,7 +43,6 @@ import { useEntryEvents } from "./hooks/useEntryEvents"; import { useUndoRedo } from "./hooks/useUndoRedo"; import { listen } from "@tauri-apps/api/event"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; -import { addRecentDatabase } from "@/lib/storage"; interface MainAppProps { onClose: (isManualLogout?: boolean) => void; From ce9e9e915857649d53ca7c4b4b23b379d0974fc2 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:49:42 +0100 Subject: [PATCH 12/22] Update components/main-app/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/main-app/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/main-app/index.tsx b/components/main-app/index.tsx index 1afae40..847d37f 100644 --- a/components/main-app/index.tsx +++ b/components/main-app/index.tsx @@ -91,7 +91,6 @@ export function MainApp({ onClose }: MainAppProps) { const [showConflictDialog, setShowConflictDialog] = useState(false); const [liveUpdatesEnabled, setLiveUpdatesEnabled] = useState(false); const [isSearchVisible, setIsSearchVisible] = useState(false); - const [selectedEntryForCopy, setSelectedEntryForCopy] = useState(null); const [passwordsVisible, setPasswordsVisible] = useState(false); const [showCreateDatabaseDialog, setShowCreateDatabaseDialog] = useState(false); const { toast } = useToast(); From d03d6818533eda7b3563fc3415415c14d115ef6f Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:52:08 +0100 Subject: [PATCH 13/22] Update components/CustomTitleBar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- components/CustomTitleBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/CustomTitleBar.tsx b/components/CustomTitleBar.tsx index daa4a6f..fe00346 100644 --- a/components/CustomTitleBar.tsx +++ b/components/CustomTitleBar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Minus, Square, X, Copy, Save, LogOut, Undo2, Redo2, Clipboard, ClipboardPaste, FolderOpen, Database as DatabaseIcon, Eye, EyeOff, Info, ExternalLink, FileText } from "lucide-react"; +import { Minus, Square, X, Save, LogOut, Undo2, Redo2, FolderOpen, Database as DatabaseIcon, Info } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Settings } from "@/components/animate-ui/icons/settings"; import { Search } from "@/components/animate-ui/icons/search"; From 991077cc82bab7744db07eabb12d0de9b48234bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:53:07 +0000 Subject: [PATCH 14/22] Remove handlePaste function and related props based on review feedback Co-authored-by: jonax1337 <25123834+jonax1337@users.noreply.github.com> --- components/CustomTitleBar.tsx | 4 +--- components/main-app/index.tsx | 10 ---------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/components/CustomTitleBar.tsx b/components/CustomTitleBar.tsx index daa4a6f..3d3f5b4 100644 --- a/components/CustomTitleBar.tsx +++ b/components/CustomTitleBar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Minus, Square, X, Copy, Save, LogOut, Undo2, Redo2, Clipboard, ClipboardPaste, FolderOpen, Database as DatabaseIcon, Eye, EyeOff, Info, ExternalLink, FileText } from "lucide-react"; +import { Minus, Square, X, Copy, Save, LogOut, Undo2, Redo2, Clipboard, FolderOpen, Database as DatabaseIcon, Eye, EyeOff, Info, ExternalLink, FileText } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Settings } from "@/components/animate-ui/icons/settings"; import { Search } from "@/components/animate-ui/icons/search"; @@ -31,7 +31,6 @@ interface CustomTitleBarProps { onUndo?: () => void; onRedo?: () => void; onCopy?: () => void; - onPaste?: () => void; onNewDatabase?: () => void; onTogglePasswords?: () => void; onAbout?: () => void; @@ -51,7 +50,6 @@ export function CustomTitleBar({ onUndo, onRedo, onCopy, - onPaste, onNewDatabase, onTogglePasswords, onAbout, diff --git a/components/main-app/index.tsx b/components/main-app/index.tsx index 004dc39..7994272 100644 --- a/components/main-app/index.tsx +++ b/components/main-app/index.tsx @@ -282,15 +282,6 @@ export function MainApp({ onClose }: MainAppProps) { } }, [selectedEntryForCopy, toast]); - const handlePaste = useCallback(() => { - toast({ - title: "Paste", - description: "Paste functionality is context-dependent", - variant: "default", - }); - }, [toast]); - - const handleNewDatabase = useCallback(() => { if (isDirty) { toast({ @@ -808,7 +799,6 @@ export function MainApp({ onClose }: MainAppProps) { onUndo={handleUndo} onRedo={handleRedo} onCopy={handleCopyPassword} - onPaste={handlePaste} onNewDatabase={handleNewDatabase} onTogglePasswords={handleTogglePasswords} onAbout={handleAbout} From 3fef9b49a17d1fae71994177e6eb33d3f0bc58b0 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:55:10 +0100 Subject: [PATCH 15/22] Add conditional new instance handling for database creation when a database is already open - Add openedInNewInstance parameter to CreateDatabaseDialog onSuccess callback - Add hasOpenDatabase prop to CreateDatabaseDialog to detect if database is currently open - Import openDatabaseInNewInstance from lib/tauri in CreateDatabaseDialog - Implement conditional logic: open new database in new instance if database already open, otherwise open in current instance - Update handleNewDatabaseSuccess --- components/CreateDatabaseDialog.tsx | 33 ++++++++++++++++++++--------- components/main-app/index.tsx | 9 +++++--- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/components/CreateDatabaseDialog.tsx b/components/CreateDatabaseDialog.tsx index cfc087a..6445a0b 100644 --- a/components/CreateDatabaseDialog.tsx +++ b/components/CreateDatabaseDialog.tsx @@ -13,7 +13,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { FolderOpen } from "lucide-react"; -import { createDatabase } from "@/lib/tauri"; +import { createDatabase, openDatabaseInNewInstance } from "@/lib/tauri"; import { useToast } from "@/components/ui/use-toast"; import { open } from "@tauri-apps/plugin-dialog"; import { saveLastDatabasePath, addRecentDatabase } from "@/lib/storage"; @@ -22,13 +22,15 @@ import { PasswordStrengthMeter } from "@/components/PasswordStrengthMeter"; interface CreateDatabaseDialogProps { isOpen: boolean; onClose: () => void; - onSuccess: () => void; + onSuccess: (openedInNewInstance: boolean) => void; + hasOpenDatabase?: boolean; } export function CreateDatabaseDialog({ isOpen, onClose, onSuccess, + hasOpenDatabase = false, }: CreateDatabaseDialogProps) { const [folderPath, setFolderPath] = useState(""); const [fileName, setFileName] = useState(""); @@ -92,21 +94,32 @@ export function CreateDatabaseDialog({ const fullPath = `${folderPath}\\${fileNameWithExt}`; await createDatabase(fullPath, password); - saveLastDatabasePath(fullPath); addRecentDatabase(fullPath); - toast({ - title: "Success", - description: "Database created successfully", - variant: "success", - }); + if (hasOpenDatabase) { + // Open in new instance if a database is already open + await openDatabaseInNewInstance(fullPath); + toast({ + title: "Success", + description: "Database created and opened in new window", + variant: "success", + }); + onSuccess(true); + } else { + // Open in current instance + saveLastDatabasePath(fullPath); + toast({ + title: "Success", + description: "Database created successfully", + variant: "success", + }); + onSuccess(false); + } setFolderPath(""); setFileName(""); setPassword(""); setConfirmPassword(""); - - onSuccess(); onClose(); } catch (error: any) { toast({ diff --git a/components/main-app/index.tsx b/components/main-app/index.tsx index 004dc39..8be9b47 100644 --- a/components/main-app/index.tsx +++ b/components/main-app/index.tsx @@ -303,9 +303,11 @@ export function MainApp({ onClose }: MainAppProps) { setShowCreateDatabaseDialog(true); }, [isDirty, toast]); - const handleNewDatabaseSuccess = useCallback(async () => { - await performClose(false); - window.location.reload(); + const handleNewDatabaseSuccess = useCallback(async (openedInNewInstance: boolean) => { + if (!openedInNewInstance) { + await performClose(false); + window.location.reload(); + } }, [performClose]); const handleTogglePasswords = useCallback(() => { @@ -921,6 +923,7 @@ export function MainApp({ onClose }: MainAppProps) { isOpen={showCreateDatabaseDialog} onClose={() => setShowCreateDatabaseDialog(false)} onSuccess={handleNewDatabaseSuccess} + hasOpenDatabase={rootGroup !== null} /> From e71bbe4961d49c01821e542a10bdd61911cf2360 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 21:56:49 +0100 Subject: [PATCH 16/22] Remove unused copy and paste handlers from MainApp component - Remove handleCopyPassword function and its dependencies (selectedEntryForCopy, toast) - Remove handlePaste function - Remove onCopy and onPaste props from child component --- components/main-app/index.tsx | 37 ----------------------------------- 1 file changed, 37 deletions(-) diff --git a/components/main-app/index.tsx b/components/main-app/index.tsx index 5d08e97..5ef1d4e 100644 --- a/components/main-app/index.tsx +++ b/components/main-app/index.tsx @@ -254,41 +254,6 @@ export function MainApp({ onClose }: MainAppProps) { setShowConflictDialog(false); }, []); - const handleCopyPassword = useCallback(async () => { - if (!selectedEntryForCopy) { - toast({ - title: "No Entry Selected", - description: "Please select an entry first", - variant: "destructive", - }); - return; - } - - try { - await writeText(selectedEntryForCopy.password); - toast({ - title: "Copied", - description: "Password copied to clipboard", - variant: "success", - }); - } catch (error: any) { - toast({ - title: "Error", - description: "Failed to copy password", - variant: "destructive", - }); - } - }, [selectedEntryForCopy, toast]); - - const handlePaste = useCallback(() => { - toast({ - title: "Paste", - description: "Paste functionality is context-dependent", - variant: "default", - }); - }, [toast]); - - const handleNewDatabase = useCallback(() => { if (isDirty) { toast({ @@ -807,8 +772,6 @@ export function MainApp({ onClose }: MainAppProps) { onToggleSearch={() => setIsSearchVisible(!isSearchVisible)} onUndo={handleUndo} onRedo={handleRedo} - onCopy={handleCopyPassword} - onPaste={handlePaste} onNewDatabase={handleNewDatabase} onTogglePasswords={handleTogglePasswords} onAbout={handleAbout} From 68bebef62fc89baf6647ff30646c27cd386d7c07 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 22:00:14 +0100 Subject: [PATCH 17/22] Add Copy icon import to CustomTitleBar component --- components/CustomTitleBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/CustomTitleBar.tsx b/components/CustomTitleBar.tsx index fe00346..bed2c5c 100644 --- a/components/CustomTitleBar.tsx +++ b/components/CustomTitleBar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Minus, Square, X, Save, LogOut, Undo2, Redo2, FolderOpen, Database as DatabaseIcon, Info } from "lucide-react"; +import { Minus, Square, X, Save, LogOut, Undo2, Copy, Redo2, FolderOpen, Database as DatabaseIcon, Info } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Settings } from "@/components/animate-ui/icons/settings"; import { Search } from "@/components/animate-ui/icons/search"; From ffeaf68bc6ed0dfdc922ee76eccf499f756d0e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:05:44 +0000 Subject: [PATCH 18/22] Initial plan From 9f7f5fac7dda0e46870e8bfdf301092423a0cab8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:11:42 +0000 Subject: [PATCH 19/22] Add validation for recent database paths to ensure they exist and are valid KDBX files Co-authored-by: jonax1337 <25123834+jonax1337@users.noreply.github.com> --- components/CustomTitleBar.tsx | 9 +++-- lib/storage.ts | 39 ++++++++++++++++++++++ lib/tauri.ts | 4 +++ package-lock.json | 17 ---------- src-tauri/src/commands/database.rs | 53 +++++++++++++++++++++++++++++- src-tauri/src/main.rs | 1 + 6 files changed, 103 insertions(+), 20 deletions(-) diff --git a/components/CustomTitleBar.tsx b/components/CustomTitleBar.tsx index ee876ed..0895b5a 100644 --- a/components/CustomTitleBar.tsx +++ b/components/CustomTitleBar.tsx @@ -17,7 +17,7 @@ import { DropdownMenuSubTrigger, } from "@/components/ui/dropdown-menu"; import { openSettingsWindow } from "@/lib/window"; -import { getRecentDatabases } from "@/lib/storage"; +import { getValidatedRecentDatabases } from "@/lib/storage"; import { openDatabaseInNewInstance } from "@/lib/tauri"; interface CustomTitleBarProps { @@ -63,7 +63,12 @@ export function CustomTitleBar({ const [recentDatabases, setRecentDatabases] = useState([]); useEffect(() => { - setRecentDatabases(getRecentDatabases()); + // Load and validate recent databases on mount + const loadRecentDatabases = async () => { + const validated = await getValidatedRecentDatabases(); + setRecentDatabases(validated); + }; + loadRecentDatabases(); }, []); const handleOpenDatabase = async (dbPath: string) => { diff --git a/lib/storage.ts b/lib/storage.ts index e7b2f80..dd32488 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { validateDatabaseFile } from "./tauri"; const LAST_DATABASE_KEY = "lastDatabasePath"; const RECENT_DATABASES_KEY = "recentDatabases"; @@ -50,6 +51,44 @@ export function getRecentDatabases(): string[] { return []; } +/** + * Validates all recent database paths and returns only valid ones. + * This function checks if each path exists and is a valid KDBX file. + * Invalid paths are automatically removed from localStorage. + */ +export async function getValidatedRecentDatabases(): Promise { + const recent = getRecentDatabases(); + if (recent.length === 0) { + return []; + } + + // Validate each path + const validationResults = await Promise.all( + recent.map(async (path) => { + try { + const isValid = await validateDatabaseFile(path); + return { path, isValid }; + } catch (error) { + // If validation fails (e.g., file system error), consider it invalid + console.warn(`Failed to validate database path: ${path}`, error); + return { path, isValid: false }; + } + }) + ); + + // Filter to only valid paths + const validPaths = validationResults + .filter(result => result.isValid) + .map(result => result.path); + + // If some paths were invalid, update localStorage to remove them + if (validPaths.length !== recent.length && typeof window !== "undefined") { + localStorage.setItem(RECENT_DATABASES_KEY, JSON.stringify(validPaths)); + } + + return validPaths; +} + export function clearRecentDatabase(path: string): void { if (typeof window !== "undefined") { const recent = getRecentDatabases(); diff --git a/lib/tauri.ts b/lib/tauri.ts index d70d375..9c17a38 100644 --- a/lib/tauri.ts +++ b/lib/tauri.ts @@ -188,3 +188,7 @@ export async function checkBreachedPasswords(): Promise { export async function openDatabaseInNewInstance(dbPath: string): Promise { return await invoke("open_database_in_new_instance", { dbPath }); } + +export async function validateDatabaseFile(path: string): Promise { + return await invoke("validate_database_file", { path }); +} diff --git a/package-lock.json b/package-lock.json index 4f0a69b..52b04a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -361,7 +360,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2921,7 +2919,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2932,7 +2929,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2982,7 +2978,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -3469,7 +3464,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3895,7 +3889,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4554,7 +4547,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4740,7 +4732,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5991,7 +5982,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6761,7 +6751,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6956,7 +6945,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6966,7 +6954,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -7716,7 +7703,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -7845,7 +7831,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8000,7 +7985,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8325,7 +8309,6 @@ "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index eac7989..67b1d9e 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -1,6 +1,6 @@ use crate::kdbx::{Database, GroupData, KdfInfo}; use crate::state::AppState; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tauri::State; use std::process::Command; @@ -181,3 +181,54 @@ pub fn get_groups(state: State) -> Result { Err("No database loaded".to_string()) } } + +#[tauri::command] +pub fn validate_database_file(path: String) -> Result { + let path_buf = PathBuf::from(&path); + + // Check if file exists + if !path_buf.exists() { + return Ok(false); + } + + // Check if it's a file (not a directory) + if !path_buf.is_file() { + return Ok(false); + } + + // Check file extension + if let Some(ext) = path_buf.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + if ext_str != "kdbx" { + return Ok(false); + } + } else { + return Ok(false); + } + + // Try to read the file header to validate it's a KDBX file + // We don't actually open it (which would require a password), + // just check if it has the KDBX magic bytes + match std::fs::read(&path_buf) { + Ok(bytes) => { + // KDBX files start with the magic signature: 0x03D9A29A (KeePass 2.x) + // Followed by version bytes + if bytes.len() < 8 { + return Ok(false); + } + + // Check for KDBX magic signature (first 4 bytes) + // Primary signature: 0x03, 0xD9, 0xA2, 0x9A + // Secondary signature follows, but checking primary is sufficient + if bytes[0] == 0x03 && bytes[1] == 0xD9 && bytes[2] == 0xA2 && bytes[3] == 0x9A { + Ok(true) + } else { + Ok(false) + } + } + Err(_) => { + // File exists but can't be read (permissions issue, etc.) + Ok(false) + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d35835e..a7c7f74 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -35,6 +35,7 @@ fn main() { commands::database::check_database_changes, commands::database::merge_database, commands::database::open_database_in_new_instance, + commands::database::validate_database_file, commands::database::get_groups, commands::entry::get_entries, commands::entry::get_favorite_entries, From 629b2880ad0a7ed5c5024e97bf5bbb705dd09c2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:13:33 +0000 Subject: [PATCH 20/22] Optimize file validation to read only header bytes instead of entire file Co-authored-by: jonax1337 <25123834+jonax1337@users.noreply.github.com> --- lib/storage.ts | 6 ++++- src-tauri/src/commands/database.rs | 37 ++++++++++++++++-------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index dd32488..abf1388 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -55,6 +55,10 @@ export function getRecentDatabases(): string[] { * Validates all recent database paths and returns only valid ones. * This function checks if each path exists and is a valid KDBX file. * Invalid paths are automatically removed from localStorage. + * + * Note: Validates paths concurrently for better performance. Since the recent + * databases list is limited to 10 items (see addRecentDatabase), this should + * not cause file system issues. */ export async function getValidatedRecentDatabases(): Promise { const recent = getRecentDatabases(); @@ -62,7 +66,7 @@ export async function getValidatedRecentDatabases(): Promise { return []; } - // Validate each path + // Validate each path concurrently (limited to 10 max by addRecentDatabase) const validationResults = await Promise.all( recent.map(async (path) => { try { diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index 67b1d9e..00d46f9 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -206,28 +206,31 @@ pub fn validate_database_file(path: String) -> Result { return Ok(false); } - // Try to read the file header to validate it's a KDBX file + // Try to read just the file header to validate it's a KDBX file // We don't actually open it (which would require a password), // just check if it has the KDBX magic bytes - match std::fs::read(&path_buf) { - Ok(bytes) => { - // KDBX files start with the magic signature: 0x03D9A29A (KeePass 2.x) - // Followed by version bytes - if bytes.len() < 8 { - return Ok(false); - } - - // Check for KDBX magic signature (first 4 bytes) - // Primary signature: 0x03, 0xD9, 0xA2, 0x9A - // Secondary signature follows, but checking primary is sufficient - if bytes[0] == 0x03 && bytes[1] == 0xD9 && bytes[2] == 0xA2 && bytes[3] == 0x9A { - Ok(true) - } else { - Ok(false) + use std::io::Read; + match std::fs::File::open(&path_buf) { + Ok(mut file) => { + let mut header = [0u8; 8]; + match file.read_exact(&mut header) { + Ok(_) => { + // KDBX files start with the magic signature: 0x03D9A29A (KeePass 2.x) + // Primary signature: 0x03, 0xD9, 0xA2, 0x9A + if header[0] == 0x03 && header[1] == 0xD9 && header[2] == 0xA2 && header[3] == 0x9A { + Ok(true) + } else { + Ok(false) + } + } + Err(_) => { + // File is too short to be a valid KDBX file + Ok(false) + } } } Err(_) => { - // File exists but can't be read (permissions issue, etc.) + // File exists but can't be opened (permissions issue, etc.) Ok(false) } } From 4ca6082c5a659573b347641fd168ffc5fa25d1b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:14:36 +0000 Subject: [PATCH 21/22] Move import to top of file and add clarification about timeout handling Co-authored-by: jonax1337 <25123834+jonax1337@users.noreply.github.com> --- lib/storage.ts | 3 +++ src-tauri/src/commands/database.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index abf1388..8451f09 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -67,6 +67,9 @@ export async function getValidatedRecentDatabases(): Promise { } // Validate each path concurrently (limited to 10 max by addRecentDatabase) + // Note: File system operations are fast (checking existence + reading 8 bytes), + // and Tauri invoke calls have built-in timeouts, so explicit timeout handling + // is not necessary here. const validationResults = await Promise.all( recent.map(async (path) => { try { diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index 00d46f9..78fbbe5 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -1,5 +1,6 @@ use crate::kdbx::{Database, GroupData, KdfInfo}; use crate::state::AppState; +use std::io::Read; use std::path::{Path, PathBuf}; use tauri::State; use std::process::Command; @@ -209,7 +210,6 @@ pub fn validate_database_file(path: String) -> Result { // Try to read just the file header to validate it's a KDBX file // We don't actually open it (which would require a password), // just check if it has the KDBX magic bytes - use std::io::Read; match std::fs::File::open(&path_buf) { Ok(mut file) => { let mut header = [0u8; 8]; From 1f6814288f27283b542765f0082e3d4d99e53b70 Mon Sep 17 00:00:00 2001 From: Jonas Laux Date: Sat, 10 Jan 2026 22:34:12 +0100 Subject: [PATCH 22/22] Move validate_database_file function to maintain logical grouping and add File import --- src-tauri/src/commands/database.rs | 100 +++++++++++++---------------- 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index 78fbbe5..e83553d 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -1,9 +1,10 @@ use crate::kdbx::{Database, GroupData, KdfInfo}; use crate::state::AppState; use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tauri::State; use std::process::Command; +use std::fs::File; #[tauri::command] pub fn get_initial_file_path(state: State) -> Option { @@ -137,6 +138,50 @@ pub fn open_database_in_new_instance(db_path: String) -> Result<(), String> { Ok(()) } +#[tauri::command] +pub fn validate_database_file(path: String) -> Result { + let path_buf = PathBuf::from(&path); + + // Check if file exists + if !path_buf.exists() { + return Ok(false); + } + + // Check if it's a file (not a directory) + if !path_buf.is_file() { + return Ok(false); + } + + // Check .kdbx extension + if let Some(ext) = path_buf.extension() { + if ext.to_string_lossy().to_lowercase() != "kdbx" { + return Ok(false); + } + } else { + return Ok(false); + } + + // Validate KDBX magic bytes (0x03D9A29A) + // KDBX format starts with these 4 bytes after the base signature + let mut file = File::open(&path_buf) + .map_err(|_| "Failed to open file for validation".to_string())?; + + let mut magic_bytes = [0u8; 8]; + if file.read_exact(&mut magic_bytes).is_err() { + // File is too small to be a valid KDBX file + return Ok(false); + } + + // Check for KDBX signature: first 4 bytes should be 0x03, 0xD9, 0xA2, 0x9A + // followed by version bytes + let valid = magic_bytes[0] == 0x03 + && magic_bytes[1] == 0xD9 + && magic_bytes[2] == 0xA2 + && magic_bytes[3] == 0x9A; + + Ok(valid) +} + #[tauri::command] pub fn merge_database(state: State) -> Result<(), String> { let mut database_lock = state.database.lock() @@ -182,56 +227,3 @@ pub fn get_groups(state: State) -> Result { Err("No database loaded".to_string()) } } - -#[tauri::command] -pub fn validate_database_file(path: String) -> Result { - let path_buf = PathBuf::from(&path); - - // Check if file exists - if !path_buf.exists() { - return Ok(false); - } - - // Check if it's a file (not a directory) - if !path_buf.is_file() { - return Ok(false); - } - - // Check file extension - if let Some(ext) = path_buf.extension() { - let ext_str = ext.to_string_lossy().to_lowercase(); - if ext_str != "kdbx" { - return Ok(false); - } - } else { - return Ok(false); - } - - // Try to read just the file header to validate it's a KDBX file - // We don't actually open it (which would require a password), - // just check if it has the KDBX magic bytes - match std::fs::File::open(&path_buf) { - Ok(mut file) => { - let mut header = [0u8; 8]; - match file.read_exact(&mut header) { - Ok(_) => { - // KDBX files start with the magic signature: 0x03D9A29A (KeePass 2.x) - // Primary signature: 0x03, 0xD9, 0xA2, 0x9A - if header[0] == 0x03 && header[1] == 0xD9 && header[2] == 0xA2 && header[3] == 0x9A { - Ok(true) - } else { - Ok(false) - } - } - Err(_) => { - // File is too short to be a valid KDBX file - Ok(false) - } - } - } - Err(_) => { - // File exists but can't be opened (permissions issue, etc.) - Ok(false) - } - } -}