diff --git a/core/src/domain/addressing.rs b/core/src/domain/addressing.rs index 26d073457350..f07b6ba40e45 100644 --- a/core/src/domain/addressing.rs +++ b/core/src/domain/addressing.rs @@ -682,7 +682,82 @@ impl SdPath { Self::Physical { .. } => Ok(self.clone()), Self::Cloud { .. } => Ok(self.clone()), // Cloud paths are already resolved Self::Content { content_id } => { - // In the future, use job_ctx.library_db() to query for content instances + use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter}; + use crate::infra::db::entities::{ + content_identity, device, location, ContentIdentity, Device, DirectoryPaths, + Entry, Location, + }; + + let db = job_ctx.library_db(); + let current_device_id = get_current_device_id(); + let current_device_slug = get_current_device_slug(); + + let ci = ContentIdentity::find() + .filter(content_identity::Column::Uuid.eq(Some(*content_id))) + .one(db) + .await + .map_err(|e| PathResolutionError::DatabaseError(e.to_string()))? + .ok_or(PathResolutionError::NoOnlineInstancesFound(*content_id))?; + + let entries = Entry::find() + .filter( + crate::infra::db::entities::entry::Column::ContentId + .eq(Some(ci.id)), + ) + .all(db) + .await + .map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?; + + for entry in entries { + let loc = Location::find() + .filter(location::Column::EntryId.eq(entry.id)) + .one(db) + .await + .map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?; + + if let Some(loc) = loc { + let dev = Device::find_by_id(loc.device_id) + .one(db) + .await + .map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?; + + if dev.map(|d| d.uuid) == Some(current_device_id) { + // Build path from directory_paths cache + let path = if let Some(parent_id) = entry.parent_id { + let parent = DirectoryPaths::find_by_id(parent_id) + .one(db) + .await + .map_err(|e| { + PathResolutionError::DatabaseError(e.to_string()) + })? + .ok_or_else(|| { + PathResolutionError::DatabaseError(format!( + "Parent path not found for entry {}", + entry.id + )) + })?; + let filename = match &entry.extension { + Some(ext) => format!("{}.{}", entry.name, ext), + None => entry.name.clone(), + }; + std::path::PathBuf::from(parent.path).join(filename) + } else { + return Err(PathResolutionError::DatabaseError( + format!( + "Entry {} has no parent_id, cannot build absolute path", + entry.id + ), + )); + }; + + return Ok(SdPath::Physical { + device_slug: current_device_slug, + path, + }); + } + } + } + Err(PathResolutionError::NoOnlineInstancesFound(*content_id)) } Self::Sidecar { content_id, .. } => { diff --git a/core/src/ops/addressing.rs b/core/src/ops/addressing.rs index f1ec8bcb93da..286425cc9cd3 100644 --- a/core/src/ops/addressing.rs +++ b/core/src/ops/addressing.rs @@ -37,7 +37,9 @@ impl PathResolver { // Cloud paths are already resolved (no additional resolution needed) SdPath::Cloud { .. } => Ok(path.clone()), // If content-based, find the optimal physical path - SdPath::Content { content_id } => unimplemented!(), + SdPath::Content { content_id } => { + Err(PathResolutionError::NoOnlineInstancesFound(*content_id)) + } // Sidecar paths need to be resolved to physical locations SdPath::Sidecar { content_id, diff --git a/core/src/ops/files/delete/job.rs b/core/src/ops/files/delete/job.rs index c07a678ac498..3bc0184d5f5a 100644 --- a/core/src/ops/files/delete/job.rs +++ b/core/src/ops/files/delete/job.rs @@ -1,6 +1,9 @@ //! Delete job implementation -use crate::{domain::addressing::SdPathBatch, infra::job::prelude::*}; +use crate::{ + domain::addressing::SdPathBatch, + infra::job::{generic_progress::GenericProgress, prelude::*}, +}; use serde::{Deserialize, Serialize}; use std::{ path::PathBuf, @@ -51,19 +54,6 @@ pub struct DeleteJob { started_at: Instant, } -/// Delete progress information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeleteProgress { - pub current_file: String, - pub files_deleted: usize, - pub total_files: usize, - pub bytes_deleted: u64, - pub total_bytes: u64, - pub current_operation: String, - pub estimated_remaining: Option, -} - -impl JobProgress for DeleteProgress {} impl Job for DeleteJob { const NAME: &'static str = "delete_files"; @@ -84,14 +74,21 @@ impl JobHandler for DeleteJob { type Output = DeleteOutput; async fn run(&mut self, ctx: JobContext<'_>) -> JobResult { + let total_files = self.targets.paths.len(); + let mode_str = match self.mode { + DeleteMode::Trash => "trash", + DeleteMode::Permanent => "permanent", + DeleteMode::Secure => "secure", + }; + ctx.log(format!( "Starting {} deletion of {} files", - match self.mode { - DeleteMode::Trash => "trash", - DeleteMode::Permanent => "permanent", - DeleteMode::Secure => "secure", - }, - self.targets.paths.len() + mode_str, total_files + )); + + // Phase: Preparing + ctx.progress(Progress::Indeterminate( + format!("Validating {} targets", total_files), )); // Safety check for permanent deletion @@ -106,6 +103,20 @@ impl JobHandler for DeleteJob { // Validate targets exist (only for local paths) self.validate_targets(&ctx).await?; + // Phase: Resolving paths + ctx.progress(Progress::Indeterminate("Resolving paths".to_string())); + + // Resolve Content paths to Physical paths before strategy selection + let mut resolved = Vec::with_capacity(self.targets.paths.len()); + for path in &self.targets.paths { + resolved.push( + path.resolve_in_job(&ctx) + .await + .map_err(|e| JobError::execution(format!("Failed to resolve path: {e}")))?, + ); + } + self.targets = SdPathBatch::new(resolved); + // Select strategy based on path topology let volume_manager = ctx.volume_manager(); let strategy = @@ -116,6 +127,11 @@ impl JobHandler for DeleteJob { DeleteStrategyRouter::describe_strategy(&self.targets.paths).await; ctx.log(format!("Using strategy: {}", strategy_description)); + // Phase: Deleting + ctx.progress(Progress::Indeterminate( + format!("Deleting {} files ({})", total_files, mode_str), + )); + // Execute deletion using selected strategy let results = strategy .execute(&ctx, &self.targets.paths, self.mode.clone()) @@ -140,6 +156,19 @@ impl JobHandler for DeleteJob { }) .collect(); + // Phase: Complete + ctx.progress(Progress::Generic( + GenericProgress::new( + 1.0, + "Complete", + format!("{} deleted, {} failed", deleted_count, failed_count), + ) + .with_completion(total_files as u64, total_files as u64) + .with_bytes(total_bytes, total_bytes) + .with_performance(0.0, None, Some(self.started_at.elapsed())) + .with_errors(failed_count as u64, 0), + )); + ctx.log(format!( "Delete operation completed: {} deleted, {} failed", deleted_count, failed_count diff --git a/packages/interface/src/routes/explorer/hooks/useDeleteFiles.ts b/packages/interface/src/routes/explorer/hooks/useDeleteFiles.ts new file mode 100644 index 000000000000..f70ef899fa51 --- /dev/null +++ b/packages/interface/src/routes/explorer/hooks/useDeleteFiles.ts @@ -0,0 +1,44 @@ +import { useCallback } from "react"; +import type { File } from "@sd/ts-client"; +import { useLibraryMutation } from "../../../contexts/SpacedriveContext"; + +/** + * Shared hook for delete file operations. + * Used by both useExplorerKeyboard (DEL key) and useFileContextMenu. + */ +export function useDeleteFiles() { + const mutation = useLibraryMutation("files.delete"); + + const deleteFiles = useCallback( + async (files: File[], permanent: boolean) => { + if (files.length === 0) return false; + if (files.some((f) => !f.sd_path)) return false; + if (mutation.isPending) return false; + + const label = permanent ? "Permanently delete" : "Delete"; + const suffix = permanent ? " This cannot be undone." : ""; + const message = + files.length > 1 + ? `${label} ${files.length} items?${suffix}` + : `${label} "${files[0].name}"?${suffix}`; + + if (!confirm(message)) return false; + + try { + await mutation.mutateAsync({ + targets: { paths: files.map((f) => f.sd_path) }, + permanent, + recursive: true, + }); + return true; + } catch (err) { + console.error("Failed to delete:", err); + alert(`Failed to delete: ${err}`); + return false; + } + }, + [mutation], + ); + + return { deleteFiles, isPending: mutation.isPending }; +} diff --git a/packages/interface/src/routes/explorer/hooks/useExplorerKeyboard.ts b/packages/interface/src/routes/explorer/hooks/useExplorerKeyboard.ts index b6e68646c8d9..1a1e6c0cbeb3 100644 --- a/packages/interface/src/routes/explorer/hooks/useExplorerKeyboard.ts +++ b/packages/interface/src/routes/explorer/hooks/useExplorerKeyboard.ts @@ -8,6 +8,7 @@ import { useKeybind } from "../../../hooks/useKeybind"; import { useKeybindScope } from "../../../hooks/useKeybindScope"; import { useClipboard } from "../../../hooks/useClipboard"; import { useFileOperationDialog } from "../../../components/modals/FileOperationModal"; +import { useDeleteFiles } from "./useDeleteFiles"; import { isInputFocused } from "../../../util/keybinds/platform"; export function useExplorerKeyboard() { @@ -36,6 +37,7 @@ export function useExplorerKeyboard() { } = useSelection(); const clipboard = useClipboard(); const openFileOperation = useFileOperationDialog(); + const { deleteFiles, isPending: isDeleting } = useDeleteFiles(); // Activate explorer keybind scope when this hook is active useKeybindScope("explorer"); @@ -160,6 +162,26 @@ export function useExplorerKeyboard() { { enabled: selectedFiles.length === 1 }, ); + // Delete: Move to trash + useKeybind( + "explorer.delete", + async () => { + const ok = await deleteFiles(selectedFiles, false); + if (ok) clearSelection(); + }, + { enabled: selectedFiles.length > 0 && !isDeleting }, + ); + + // Permanent Delete: Shift+Delete / Cmd+Alt+Backspace + useKeybind( + "explorer.permanentDelete", + async () => { + const ok = await deleteFiles(selectedFiles, true); + if (ok) clearSelection(); + }, + { enabled: selectedFiles.length > 0 && !isDeleting }, + ); + useEffect(() => { const handleKeyDown = async (e: KeyboardEvent) => { // Skip all keyboard shortcuts if renaming or typing in an input diff --git a/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts b/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts index 3c1c0d019f72..9d5c034b03f9 100644 --- a/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts +++ b/packages/interface/src/routes/explorer/hooks/useFileContextMenu.ts @@ -33,6 +33,7 @@ import { useClipboard } from "../../../hooks/useClipboard"; import { useFileOperationDialog } from "../../../components/modals/FileOperationModal"; import { useSelection } from "../SelectionContext"; import { useOpenWith } from "../../../hooks/useOpenWith"; +import { useDeleteFiles } from "./useDeleteFiles"; interface UseFileContextMenuProps { file?: File | null; @@ -48,7 +49,7 @@ export function useFileContextMenu({ const { navigateToPath, currentPath } = useExplorer(); const platform = usePlatform(); const copyFiles = useLibraryMutation("files.copy"); - const deleteFiles = useLibraryMutation("files.delete"); + const { deleteFiles } = useDeleteFiles(); const createFolder = useLibraryMutation("files.createFolder"); const { runJob } = useJobDispatch(); const clipboard = useClipboard(); @@ -532,60 +533,7 @@ export function useFileContextMenu({ : "Delete", onClick: async () => { const targets = getTargetFiles(); - if (targets.length === 0) { - console.warn("Cannot delete virtual files"); - return; - } - const message = - targets.length > 1 - ? `Delete ${targets.length} items?` - : `Delete "${file?.name ?? "this file"}"?`; - - if (confirm(message)) { - console.log( - "Deleting files:", - targets.map((f) => f.name), - ); - - try { - const result = await deleteFiles.mutateAsync({ - targets: { - paths: targets.map((f) => f.sd_path), - }, - permanent: false, - recursive: true, - }); - - console.log("Delete result:", result); - - // Check if it's a confirmation request - if ( - result && - typeof result === "object" && - "NeedsConfirmation" in result - ) { - console.log( - "Delete needs confirmation:", - result, - ); - alert( - "Delete confirmation UI not implemented yet", - ); - } else if ( - result && - typeof result === "object" && - "job_id" in result - ) { - console.log( - "Delete job started:", - result.job_id, - ); - } - } catch (err) { - console.error("Failed to delete:", err); - alert(`Failed to delete: ${err}`); - } - } + await deleteFiles(targets, false); }, keybind: "⌘⌫", variant: "danger" as const,