From 4e5923ba844dfa14077cfd17cfea2f07b1cd23c7 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 7 Jun 2026 09:16:56 +0100 Subject: [PATCH] feat(folders): add rename, missing-folder recovery, and right-click context menu - Backend: new `rename_folder` command (updates display name only, not path) and `update_folder_path` command (rewrites image paths in DB before reindexing so thumbnails/embeddings are not regenerated unnecessarily) - DB: `rename_folder` and `update_folder_path` helpers; `scan_error` column on folders table to surface indexing failures without silently dropping images - Store: `renameFolder` and `updateFolderPath` actions - Sidebar: right-click context menu on each folder row (Reindex, Rename, Locate Folder, Remove from app); inline rename input on Enter/Escape/blur; missing- folder recovery banner with Locate/Remove; hover icon buttons (reindex + remove with Confirm/Cancel) preserved alongside the context menu --- src-tauri/src/commands.rs | 14 ++ src-tauri/src/db.rs | 8 + src-tauri/src/lib.rs | 1 + src/components/Sidebar.tsx | 354 ++++++++++++++++++++++++------------- src/store.ts | 6 + 5 files changed, 258 insertions(+), 125 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 434cfd4..53982b3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -325,6 +325,20 @@ pub async fn reindex_folder( Ok(()) } +#[tauri::command] +pub async fn rename_folder( + db: State<'_, DbState>, + folder_id: i64, + new_name: String, +) -> Result<(), String> { + let new_name = new_name.trim().to_string(); + if new_name.is_empty() { + return Err("Folder name cannot be empty".to_string()); + } + let conn = db.get().map_err(|e| e.to_string())?; + db::rename_folder(&conn, folder_id, &new_name).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn update_folder_path( app: AppHandle, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 549e59d..e102004 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1356,6 +1356,14 @@ pub fn get_folders(conn: &Connection) -> Result> { Ok(rows.collect::>>()?) } +pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> { + conn.execute( + "UPDATE folders SET name = ?2 WHERE id = ?1", + params![folder_id, new_name], + )?; + Ok(()) +} + pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new_path: &str, new_name: &str) -> Result<()> { conn.execute( "UPDATE folders SET path = ?2, name = ?3, scan_error = NULL WHERE id = ?1", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 26522b5..165219f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -133,6 +133,7 @@ pub fn run() { commands::find_duplicates, commands::load_duplicate_scan_cache, commands::delete_images_from_disk, + commands::rename_folder, commands::update_folder_path, ]) .run(tauri::generate_context!()) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 84c039f..18f2fce 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,73 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { useGalleryStore, Folder, IndexProgress } from "../store"; +interface ContextMenuState { + folderId: number; + x: number; + y: number; +} + +function FolderContextMenu({ + menu, + folder, + onClose, + onRename, + onReindex, + onLocate, + onRemove, +}: { + menu: ContextMenuState; + folder: Folder; + onClose: () => void; + onRename: () => void; + onReindex: () => void; + onLocate: () => void; + onRemove: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + const handleDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("mousedown", handleDown); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleDown); + document.removeEventListener("keydown", handleKey); + }; + }, [onClose]); + + const item = (label: string, onClick: () => void, danger = false) => ( + + ); + + return ( +
+ {item("Reindex", onReindex)} + {item("Rename", onRename)} + {folder.scan_error && item("Locate Folder", onLocate)} +
+ {item("Remove from app", onRemove, true)} +
+ ); +} + function FolderItem({ folder, selected, @@ -11,148 +77,186 @@ function FolderItem({ selected: boolean; progress: IndexProgress | undefined; }) { - const { selectFolder, removeFolder, reindexFolder, updateFolderPath } = useGalleryStore(); + const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath } = useGalleryStore(); const isIndexing = progress && !progress.done; - const [confirmingRemoval, setConfirmingRemoval] = useState(false); const isMissing = !!folder.scan_error && !isIndexing; - const handleLocateFolder = async (e: React.MouseEvent) => { + const [contextMenu, setContextMenu] = useState(null); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(folder.name); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + const renameInputRef = useRef(null); + + useEffect(() => { + if (renaming) { + setRenameValue(folder.name); + setTimeout(() => renameInputRef.current?.select(), 0); + } + }, [renaming, folder.name]); + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); e.stopPropagation(); - const selected = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` }); - if (selected && typeof selected === "string") { - await updateFolderPath(folder.id, selected); + // Keep menu inside viewport + const x = Math.min(e.clientX, window.innerWidth - 180); + const y = Math.min(e.clientY, window.innerHeight - 160); + setContextMenu({ folderId: folder.id, x, y }); + }; + + const handleLocateFolder = async () => { + const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` }); + if (picked && typeof picked === "string") { + await updateFolderPath(folder.id, picked); } }; - useEffect(() => { - if (!confirmingRemoval) return; + const commitRename = async () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== folder.name) { + await renameFolder(folder.id, trimmed); + } + setRenaming(false); + }; - const timeout = window.setTimeout(() => { - setConfirmingRemoval(false); - }, 4000); - - return () => window.clearTimeout(timeout); - }, [confirmingRemoval]); - - const handleRemove = async (event: React.MouseEvent) => { - event.stopPropagation(); - await removeFolder(folder.id); + const handleRenameKey = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { e.preventDefault(); void commitRename(); } + if (e.key === "Escape") { setRenaming(false); } }; return ( <> -
selectFolder(folder.id)} - > - {folder.scan_error ? ( - - - - - - ) : ( - - - - )} - -
-
- {folder.name} -
- {isIndexing ? ( - <> -
{progress.indexed}/{progress.total}
-
-
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} - /> -
- +
!renaming && selectFolder(folder.id)} + onContextMenu={handleContextMenu} + > + {isMissing ? ( + + + + + ) : ( -
{folder.image_count.toLocaleString()}
+ + + + )} + +
+ {renaming ? ( + setRenameValue(e.target.value)} + onKeyDown={handleRenameKey} + onBlur={() => void commitRename()} + onClick={(e) => e.stopPropagation()} + /> + ) : ( +
+ {folder.name} +
+ )} + {isIndexing ? ( + <> +
{progress.indexed}/{progress.total}
+
+
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} + /> +
+ + ) : ( +
{folder.image_count.toLocaleString()}
+ )} +
+ + {/* Hover action buttons */} + {!renaming && ( + confirmingRemoval ? ( +
e.stopPropagation()}> + + +
+ ) : ( +
+ + +
+ ) )}
- {confirmingRemoval ? ( -
event.stopPropagation()} - > - - -
- ) : ( -
- - + {isMissing && ( +
+

Folder not found

+

+ This folder may have been moved or renamed. Locate it to resume, or remove it from the app. +

+
+ + +
)} -
- {isMissing && ( -
-

Folder not found

-

- This folder may have been moved or renamed. Locate it to resume, or remove it from the app. -

-
- - -
-
- )} + + {contextMenu && contextMenu.folderId === folder.id && ( + setContextMenu(null)} + onRename={() => setRenaming(true)} + onReindex={() => void reindexFolder(folder.id)} + onLocate={() => void handleLocateFolder()} + onRemove={() => setConfirmingRemoval(true)} + /> + )} ); } diff --git a/src/store.ts b/src/store.ts index 1ebb358..d0c404f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -287,6 +287,7 @@ interface GalleryState { addFolder: (path: string) => Promise; removeFolder: (folderId: number) => Promise; reindexFolder: (folderId: number) => Promise; + renameFolder: (folderId: number, newName: string) => Promise; updateFolderPath: (folderId: number, newPath: string) => Promise; selectFolder: (folderId: number | null) => void; loadImages: (reset?: boolean) => Promise; @@ -671,6 +672,11 @@ export const useGalleryStore = create((set, get) => ({ await loadBackgroundJobProgress(); }, + renameFolder: async (folderId, newName) => { + await invoke("rename_folder", { folderId, newName }); + await get().loadFolders(); + }, + updateFolderPath: async (folderId, newPath) => { const { loadFolders, loadBackgroundJobProgress } = get(); await invoke("update_folder_path", { folderId, newPath });