diff --git a/backend/app/api/system.py b/backend/app/api/system.py index cb7d241..ee82469 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -1485,6 +1485,8 @@ def get_discrepancies_tree( return result # Return immediate children of the given path + if path is None: + return [] result = [] for dir_path, node in sorted(dir_nodes.items()): if dir_path == path: @@ -1587,16 +1589,19 @@ def browse_discrepancies( child_name = file_path else: # Check if this file is under the requested path - if file_path != path and not file_path.startswith(path + "/"): + if path is None or ( + file_path != path and not file_path.startswith(path + "/") + ): continue # Get immediate child relative to path - rel_path = file_path[len(path) :].strip("/") + path_str = path or "" + rel_path = file_path[len(path_str) :].strip("/") if "/" in rel_path: # It's a subdirectory - get immediate child child_name = rel_path.split("/")[0] child_path = ( - path + "/" + child_name if path != "/" else "/" + child_name + path_str + "/" + child_name if path_str != "/" else "/" + child_name ) else: # It's a file diff --git a/frontend/src/lib/components/file-browser/FileBrowser.svelte b/frontend/src/lib/components/file-browser/FileBrowser.svelte index b40034e..36b3e56 100644 --- a/frontend/src/lib/components/file-browser/FileBrowser.svelte +++ b/frontend/src/lib/components/file-browser/FileBrowser.svelte @@ -32,6 +32,7 @@ currentPath = $bindable("ROOT"), searchQuery = $bindable(""), files = [], + selectedPaths = $bindable(new Set()), onNavigate = (path: string) => {}, onToggleTrack = (item: FileItem) => {}, onSelect = (item: FileItem) => {}, @@ -44,6 +45,7 @@ currentPath?: string; searchQuery?: string; files?: FileItem[]; + selectedPaths?: Set; onNavigate?: (path: string) => void; onToggleTrack?: (item: FileItem) => void; onSelect?: (item: FileItem) => void; @@ -54,7 +56,6 @@ pendingChanges?: Map; }>(); - let selectedPaths = $state>(new Set()); let lastSelectedPath = $state(null); let sortColumn = $state<"name" | "size" | "mtime" | "type">("name"); let sortDirection = $state<"asc" | "desc">("asc"); @@ -284,6 +285,13 @@ } function handleRowClick(e: MouseEvent, item: FileItem) { + // For discrepancies mode, row click navigates (dirs) or shows metadata (files) + // Checkbox handles selection + if (mode === 'discrepancies') { + onSelect(item); + return; + } + if (e.shiftKey && lastSelectedPath) { const lastIndex = filteredFiles.findIndex((f: FileItem) => f.path === lastSelectedPath); const currentIndex = filteredFiles.findIndex((f: FileItem) => f.path === item.path); @@ -321,14 +329,51 @@ } } + // Recursively add directory and all its children to selection + function addItemAndChildren(path: string, newSelection: Set) { + newSelection.add(path); + // Find all files/dirs that are children of this path + for (const f of (files as FileItem[])) { + if (f.path.startsWith(path + "/") || f.path === path) { + newSelection.add(f.path); + } + } + } + + // Recursively remove directory and all its children from selection + function removeItemAndChildren(path: string, newSelection: Set) { + newSelection.delete(path); + for (const f of (files as FileItem[])) { + if (f.path.startsWith(path + "/")) { + newSelection.delete(f.path); + } + } + } + function handleSelectAll(checked: boolean | "indeterminate") { if (checked === true) { - selectedPaths = new Set(filteredFiles.map((f: FileItem) => f.path)); + const newSelection = new Set(); + for (const f of (filteredFiles as FileItem[])) { + addItemAndChildren(f.path, newSelection); + } + selectedPaths = newSelection; } else { selectedPaths = new Set(); } } + function handleToggleItem(item: FileItem) { + const newSelection = new Set(selectedPaths as Set); + if (newSelection.has(item.path)) { + // Deselecting - remove item and all children if it's a directory + removeItemAndChildren(item.path, newSelection); + } else { + // Selecting - add item and all children if it's a directory + addItemAndChildren(item.path, newSelection); + } + selectedPaths = newSelection; + } + let isEditingPath = $state(false); let pathInputValue = $state(""); @@ -605,6 +650,7 @@ onClick={(e) => handleRowClick(e, item)} onDoubleClick={() => handleRowDoubleClick(item)} onToggleTrack={() => onToggleTrack(item)} + onToggleSelect={() => handleToggleItem(item)} onAddToCart={() => onAddToCart(item)} onDelete={() => onDelete(item)} /> diff --git a/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte b/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte index cae5ef9..1c46256 100644 --- a/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte +++ b/frontend/src/lib/components/file-browser/FileBrowserRowItem.svelte @@ -28,6 +28,7 @@ onClick = (e: MouseEvent) => {}, onDoubleClick = () => {}, onToggleTrack = () => {}, + onToggleSelect = () => {}, onAddToCart = () => {}, onDelete = () => {}, mode = "host", @@ -39,6 +40,7 @@ onClick?: (e: MouseEvent) => void; onDoubleClick?: () => void; onToggleTrack?: () => void; + onToggleSelect?: () => void; onAddToCart?: () => void; onDelete?: () => void; mode?: "host" | "index" | "live" | "cart" | "discrepancies"; @@ -122,12 +124,16 @@ ondblclick={(e) => { e.stopPropagation(); onDoubleClick(); }} onkeydown={(e) => e.key === "Enter" && onDoubleClick()} > - +
{ e.stopPropagation(); - onToggleTrack(); + if (mode === 'discrepancies') { + onToggleSelect(); + } else { + onToggleTrack(); + } }} onkeydown={(e) => e.key === " " && e.stopPropagation()} role="none" @@ -150,6 +156,11 @@
{/if} + {:else if mode === 'discrepancies'} + {:else} ([]); let loading = $state(true); let currentPath = $state("ROOT"); - let selectedIds = $state>(new Set()); - let batchAction = $state<'acknowledge' | 'recover' | null>(null); + let selectedPaths = $state>(new Set()); let batchLoading = $state(false); async function loadDiscrepancies() { @@ -105,6 +104,78 @@ } } + async function batchDismiss() { + const ids = getDiscrepancyIdsFromPaths(selectedPaths); + if (ids.length === 0) { + toast.error("No files selected"); + return; + } + batchLoading = true; + try { + await batchDismissSystemDiscrepanciesBatchDismissPost({ + body: { ids } + }); + toast.success(`Dismissed ${ids.length} files`); + selectedPaths = new Set(); + await loadDiscrepancies(); + } catch (error: any) { + toast.error(error.body?.detail || "Failed to dismiss files"); + } finally { + batchLoading = false; + } + } + + async function batchDelete() { + const ids = getDiscrepancyIdsFromPaths(selectedPaths); + if (ids.length === 0) { + toast.error("No files selected"); + return; + } + batchLoading = true; + try { + await batchHardDeleteSystemDiscrepanciesBatchDeletePost({ + body: { ids } + }); + toast.success(`Deleted ${ids.length} file records`); + selectedPaths = new Set(); + await loadDiscrepancies(); + } catch (error: any) { + toast.error(error.body?.detail || "Failed to delete files"); + } finally { + batchLoading = false; + } + } + + async function batchAddToCart() { + const ids = getDiscrepancyIdsFromPaths(selectedPaths); + if (ids.length === 0) { + toast.error("No files selected"); + return; + } + batchLoading = true; + try { + await batchAddToRecoveryQueueRestoresQueueBatchPost({ + body: { ids } + }); + toast.success(`Added ${ids.length} files to restore cart`); + selectedPaths = new Set(); + } catch (error: any) { + toast.error(error.body?.detail || "Failed to add files to cart"); + } finally { + batchLoading = false; + } + } + + function getDiscrepancyIdsFromPaths(paths: Set): number[] { + const ids: number[] = []; + for (const item of files) { + if (paths.has(item.path) && item.discrepancy_id) { + ids.push(item.discrepancy_id); + } + } + return ids; + } + function navigateTo(path: string) { currentPath = path; loadFiles(path); @@ -176,10 +247,32 @@ /> + + {#if selectedPaths.size > 0} +
+ + {selectedPaths.size} file(s) selected + +
+ + + +
+
+ {/if} +