From 8b2ed0e700fff2f244065acfbbc99337e760624f Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Thu, 4 Sep 2025 16:26:23 +0200 Subject: [PATCH] delete multiple files with selection --- .../domain/warren/models/file/requests/mod.rs | 2 +- .../domain/warren/models/file/requests/rm.rs | 24 +++++----- .../domain/warren/models/warren/requests.rs | 29 ++++++------ backend/src/lib/domain/warren/ports/mod.rs | 12 +++-- .../src/lib/domain/warren/ports/notifier.rs | 2 +- .../src/lib/domain/warren/ports/repository.rs | 12 +++-- .../lib/domain/warren/service/file_system.rs | 26 ++++++----- .../src/lib/domain/warren/service/warren.rs | 26 +++++------ backend/src/lib/inbound/http/errors.rs | 10 ++-- .../http/handlers/warrens/warren_rm.rs | 28 ++++++++--- backend/src/lib/outbound/file_system.rs | 42 ++++++++++++----- .../src/lib/outbound/notifier_debug_logger.rs | 31 ++++++++++--- frontend/components/DirectoryEntry.vue | 35 +++----------- frontend/components/DirectoryList.vue | 6 +++ frontend/lib/api/warrens.ts | 46 ++++++++++++++++--- frontend/pages/warrens/files.vue | 39 ++++++++++++---- frontend/utils/selection.ts | 17 +++++++ 17 files changed, 250 insertions(+), 137 deletions(-) create mode 100644 frontend/utils/selection.ts diff --git a/backend/src/lib/domain/warren/models/file/requests/mod.rs b/backend/src/lib/domain/warren/models/file/requests/mod.rs index 823d0b0..aa800c6 100644 --- a/backend/src/lib/domain/warren/models/file/requests/mod.rs +++ b/backend/src/lib/domain/warren/models/file/requests/mod.rs @@ -48,7 +48,7 @@ impl AbsoluteFilePathList { } } -#[derive(Debug, Error)] +#[derive(Debug, Clone, Error)] pub enum AbsoluteFilePathListError { #[error("A list must not be empty")] Empty, diff --git a/backend/src/lib/domain/warren/models/file/requests/rm.rs b/backend/src/lib/domain/warren/models/file/requests/rm.rs index 52ceab0..bc65481 100644 --- a/backend/src/lib/domain/warren/models/file/requests/rm.rs +++ b/backend/src/lib/domain/warren/models/file/requests/rm.rs @@ -1,24 +1,24 @@ use thiserror::Error; -use crate::domain::warren::models::file::AbsoluteFilePath; +use crate::domain::warren::models::file::{AbsoluteFilePath, AbsoluteFilePathList}; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RmRequest { - path: AbsoluteFilePath, + paths: AbsoluteFilePathList, force: bool, } impl RmRequest { - pub fn new(path: AbsoluteFilePath, force: bool) -> Self { - Self { path, force } + pub fn new(paths: AbsoluteFilePathList, force: bool) -> Self { + Self { paths, force } } - pub fn path(&self) -> &AbsoluteFilePath { - &self.path + pub fn paths(&self) -> &AbsoluteFilePathList { + &self.paths } - pub fn into_path(self) -> AbsoluteFilePath { - self.path + pub fn into_paths(self) -> AbsoluteFilePathList { + self.paths } pub fn force(&self) -> bool { @@ -28,10 +28,10 @@ impl RmRequest { #[derive(Debug, Error)] pub enum RmError { - #[error("The path does not exist")] - NotFound, - #[error("The directory is not empty")] - NotEmpty, + #[error("At least one file does not exist")] + NotFound(AbsoluteFilePath), + #[error("At least one directory is not empty")] + NotEmpty(AbsoluteFilePath), #[error(transparent)] Unknown(#[from] anyhow::Error), } diff --git a/backend/src/lib/domain/warren/models/warren/requests.rs b/backend/src/lib/domain/warren/models/warren/requests.rs index 09cb6e0..12f0a11 100644 --- a/backend/src/lib/domain/warren/models/warren/requests.rs +++ b/backend/src/lib/domain/warren/models/warren/requests.rs @@ -221,12 +221,15 @@ impl WarrenRmRequest { pub fn build_fs_request(self, warren: &Warren) -> RmRequest { let force = self.base.force(); - let path = warren - .path() - .clone() - .join(&self.base.into_path().to_relative()); - RmRequest::new(path, force) + let mut paths = self.base.into_paths(); + + paths + .paths_mut() + .into_iter() + .for_each(|path| *path = warren.path.clone().join(&path.clone().to_relative())); + + RmRequest::new(paths, force) } } @@ -242,34 +245,30 @@ impl Into for &WarrenRmRequest { } } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug)] pub struct WarrenRmResponse { warren: Warren, - path: AbsoluteFilePath, + results: Vec>, } impl WarrenRmResponse { - pub fn new(warren: Warren, path: AbsoluteFilePath) -> Self { - Self { warren, path } + pub fn new(warren: Warren, results: Vec>) -> Self { + Self { warren, results } } pub fn warren(&self) -> &Warren { &self.warren } - pub fn path(&self) -> &AbsoluteFilePath { - &self.path + pub fn results(&self) -> &Vec> { + &self.results } } #[derive(Debug, Error)] pub enum WarrenRmError { - #[error(transparent)] - FileSystem(#[from] RmError), #[error(transparent)] FetchWarren(#[from] FetchWarrenError), - #[error(transparent)] - Unknown(#[from] anyhow::Error), } pub struct WarrenSaveRequest<'s> { diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 105f6e8..3916143 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -15,9 +15,10 @@ use super::models::{ }, }, file::{ - CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest, - LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError, - SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest, + AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, + LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, + RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, + TouchError, TouchRequest, }, share::{ CreateShareBaseRequest, CreateShareError, CreateShareRequest, CreateShareResponse, @@ -144,7 +145,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static { fn cat(&self, request: CatRequest) -> impl Future> + Send; fn mkdir(&self, request: MkdirRequest) -> impl Future> + Send; - fn rm(&self, request: RmRequest) -> impl Future> + Send; + fn rm( + &self, + request: RmRequest, + ) -> impl Future>> + Send; fn mv(&self, request: MvRequest) -> impl Future> + Send; fn save( &self, diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index 35e8f0c..f92033e 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -68,7 +68,7 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static { pub trait FileSystemNotifier: Clone + Send + Sync + 'static { fn ls(&self, response: &LsResponse) -> impl Future + Send; - fn cat(&self, path: &AbsoluteFilePathList) -> impl Future + Send; + fn cat(&self, paths: &AbsoluteFilePathList) -> impl Future + Send; fn mkdir(&self, path: &AbsoluteFilePath) -> impl Future + Send; fn rm(&self, path: &AbsoluteFilePath) -> impl Future + Send; fn mv( diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index aa89744..02ffb99 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -7,9 +7,10 @@ use crate::domain::warren::models::{ }, }, file::{ - CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest, - LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError, - SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest, + AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, + LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, + RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, + TouchError, TouchRequest, }, share::{ CreateShareError, CreateShareRequest, CreateShareResponse, DeleteShareError, @@ -97,7 +98,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static { fn cat(&self, request: CatRequest) -> impl Future> + Send; fn mkdir(&self, request: MkdirRequest) -> impl Future> + Send; - fn rm(&self, request: RmRequest) -> impl Future> + Send; + fn rm( + &self, + request: RmRequest, + ) -> impl Future>> + Send; fn mv(&self, request: MvRequest) -> impl Future> + Send; fn save( &self, diff --git a/backend/src/lib/domain/warren/service/file_system.rs b/backend/src/lib/domain/warren/service/file_system.rs index 62d6cf9..f539781 100644 --- a/backend/src/lib/domain/warren/service/file_system.rs +++ b/backend/src/lib/domain/warren/service/file_system.rs @@ -1,8 +1,9 @@ use crate::domain::warren::{ models::file::{ - CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest, - LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError, - SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest, + AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, + LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, + RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, + TouchError, TouchRequest, }, ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService}, }; @@ -81,18 +82,19 @@ where result } - async fn rm(&self, request: RmRequest) -> Result<(), RmError> { - let path = request.path().clone(); - let result = self.repository.rm(request).await; + async fn rm(&self, request: RmRequest) -> Vec> { + let results = self.repository.rm(request).await; - if result.is_ok() { - self.metrics.record_rm_success().await; - self.notifier.rm(&path).await; - } else { - self.metrics.record_rm_failure().await; + for result in results.iter() { + if let Ok(path) = result.as_ref() { + self.metrics.record_rm_success().await; + self.notifier.rm(path).await; + } else { + self.metrics.record_rm_failure().await; + } } - result + results } async fn mv(&self, request: MvRequest) -> Result<(), MvError> { diff --git a/backend/src/lib/domain/warren/service/warren.rs b/backend/src/lib/domain/warren/service/warren.rs index d1e142e..dbe7769 100644 --- a/backend/src/lib/domain/warren/service/warren.rs +++ b/backend/src/lib/domain/warren/service/warren.rs @@ -250,26 +250,22 @@ where } async fn warren_rm(&self, request: WarrenRmRequest) -> Result { - let warren = self.repository.fetch_warren((&request).into()).await?; + let warren = match self.repository.fetch_warren((&request).into()).await { + Ok(warren) => warren, + Err(e) => { + self.metrics.record_warren_rm_failure().await; + return Err(e.into()); + } + }; - let path = request.base().path().clone(); let rm_request = request.build_fs_request(&warren); - let result = self - .fs_service - .rm(rm_request) - .await - .map(|_| WarrenRmResponse::new(warren, path)) - .map_err(Into::into); + let response = WarrenRmResponse::new(warren, self.fs_service.rm(rm_request).await); - if let Ok(response) = result.as_ref() { - self.metrics.record_warren_rm_success().await; - self.notifier.warren_rm(response).await; - } else { - self.metrics.record_warren_rm_failure().await; - } + self.metrics.record_warren_rm_success().await; + self.notifier.warren_rm(&response).await; - result + Ok(response) } async fn warren_mv(&self, request: WarrenMvRequest) -> Result { diff --git a/backend/src/lib/inbound/http/errors.rs b/backend/src/lib/inbound/http/errors.rs index 9991213..77aaa9f 100644 --- a/backend/src/lib/inbound/http/errors.rs +++ b/backend/src/lib/inbound/http/errors.rs @@ -41,8 +41,12 @@ impl From for ApiError { impl From for ApiError { fn from(value: RmError) -> Self { match value { - RmError::NotFound => Self::NotFound("The directory does not exist".to_string()), - RmError::NotEmpty => Self::BadRequest("The directory is not empty".to_string()), + RmError::NotFound(_) => { + Self::NotFound("At least one of the specified files does not exist".to_string()) + } + RmError::NotEmpty(_) => Self::BadRequest( + "At least one of the specified directories does not exist".to_string(), + ), RmError::Unknown(e) => Self::InternalServerError(e.to_string()), } } @@ -51,9 +55,7 @@ impl From for ApiError { impl From for ApiError { fn from(value: WarrenRmError) -> Self { match value { - WarrenRmError::FileSystem(fs) => fs.into(), WarrenRmError::FetchWarren(err) => err.into(), - WarrenRmError::Unknown(error) => Self::InternalServerError(error.to_string()), } } } diff --git a/backend/src/lib/inbound/http/handlers/warrens/warren_rm.rs b/backend/src/lib/inbound/http/handlers/warrens/warren_rm.rs index 32f4039..d92025b 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/warren_rm.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/warren_rm.rs @@ -7,7 +7,10 @@ use crate::{ domain::warren::{ models::{ auth_session::AuthRequest, - file::{AbsoluteFilePathError, FilePath, FilePathError, RmRequest}, + file::{ + AbsoluteFilePath, AbsoluteFilePathError, AbsoluteFilePathList, + AbsoluteFilePathListError, FilePath, FilePathError, RmRequest, + }, warren::WarrenRmRequest, }, ports::{AuthService, WarrenService}, @@ -23,7 +26,7 @@ use crate::{ #[serde(rename_all = "camelCase")] pub(super) struct WarrenRmHttpRequestBody { warren_id: Uuid, - path: String, + paths: Vec, force: bool, } @@ -33,6 +36,8 @@ pub(super) enum ParseWarrenRmHttpRequestError { FilePath(#[from] FilePathError), #[error(transparent)] AbsoluteFilePath(#[from] AbsoluteFilePathError), + #[error(transparent)] + AbsoluteFilePathList(#[from] AbsoluteFilePathListError), } impl From for ApiError { @@ -40,12 +45,17 @@ impl From for ApiError { match value { ParseWarrenRmHttpRequestError::FilePath(err) => match err { FilePathError::InvalidPath => { - ApiError::BadRequest("The file path must be valid".to_string()) + ApiError::BadRequest("File paths must be valid".to_string()) } }, ParseWarrenRmHttpRequestError::AbsoluteFilePath(err) => match err { AbsoluteFilePathError::NotAbsolute => { - ApiError::BadRequest("The file path must be absolute".to_string()) + ApiError::BadRequest("File paths must be absolute".to_string()) + } + }, + ParseWarrenRmHttpRequestError::AbsoluteFilePathList(err) => match err { + AbsoluteFilePathListError::Empty => { + Self::BadRequest("At least one file path is required".to_string()) } }, } @@ -54,11 +64,17 @@ impl From for ApiError { impl WarrenRmHttpRequestBody { fn try_into_domain(self) -> Result { - let path = FilePath::new(&self.path)?; + let mut paths = Vec::::new(); + + for path in self.paths.iter() { + paths.push(FilePath::new(path)?.try_into()?); + } + + let path_list = AbsoluteFilePathList::new(paths)?; Ok(WarrenRmRequest::new( self.warren_id, - RmRequest::new(path.try_into()?, self.force), + RmRequest::new(path_list, self.force), )) } } diff --git a/backend/src/lib/outbound/file_system.rs b/backend/src/lib/outbound/file_system.rs index cb477f8..66940cb 100644 --- a/backend/src/lib/outbound/file_system.rs +++ b/backend/src/lib/outbound/file_system.rs @@ -1,5 +1,5 @@ use anyhow::{Context as _, anyhow, bail}; -use futures_util::TryStreamExt; +use futures_util::{TryStreamExt, future::join_all}; use rustix::fs::{Statx, statx}; use std::{ collections::HashSet, @@ -182,10 +182,10 @@ impl FileSystem { /// Actually removes a file or directory from the underlying file system /// - /// * `path`: The directory's absolute path (absolute not in relation to the root file system but `self.base_directory`) + /// * `path`: The file's absolute path (absolute not in relation to the root file system but `self.base_directory`) /// * `force`: Whether to delete directories that are not empty async fn rm(&self, path: &AbsoluteFilePath, force: bool) -> io::Result<()> { - let file_path = self.get_target_path(path); + let file_path = self.get_target_path(&path); if fs::metadata(&file_path).await?.is_file() { return fs::remove_file(&file_path).await; @@ -412,14 +412,34 @@ impl FileSystemRepository for FileSystem { }) } - async fn rm(&self, request: RmRequest) -> Result<(), RmError> { - self.rm(request.path(), request.force()) - .await - .map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => RmError::NotFound, - std::io::ErrorKind::DirectoryNotEmpty => RmError::NotEmpty, - _ => anyhow!("Failed to delete file at {}: {e:?}", request.path()).into(), - }) + async fn rm(&self, request: RmRequest) -> Vec> { + let force = request.force(); + let paths: Vec = request.into_paths().into(); + + async fn _rm( + fs: &FileSystem, + path: AbsoluteFilePath, + force: bool, + ) -> Result { + fs.rm(&path, force) + .await + .map(|_| path.clone()) + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => RmError::NotFound(path), + std::io::ErrorKind::DirectoryNotEmpty => RmError::NotEmpty(path), + _ => anyhow!("Failed to delete file at {}: {e:?}", path).into(), + }) + } + + let results: Vec> = join_all( + paths + .into_iter() + .map(|path| _rm(&self, path, force)) + .collect::>(), + ) + .await; + + results } async fn mv(&self, request: MvRequest) -> Result<(), MvError> { diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 177d720..143525b 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -87,11 +87,26 @@ impl WarrenNotifier for NotifierDebugLogger { } async fn warren_rm(&self, response: &WarrenRmResponse) { - tracing::debug!( - "[Notifier] Deleted file {} from warren {}", - response.path(), - response.warren().name(), - ); + let span = tracing::debug_span!("warren_rm", "{}", response.warren().name()).entered(); + + let results = response.results(); + + for result in results { + match result.as_ref() { + Ok(path) => tracing::debug!("Deleted file: {path}"), + Err(e) => match e { + crate::domain::warren::models::file::RmError::NotFound(path) => { + tracing::debug!("File not found: {path}") + } + crate::domain::warren::models::file::RmError::NotEmpty(path) => { + tracing::debug!("Directory not empty: {path}") + } + crate::domain::warren::models::file::RmError::Unknown(_) => (), + }, + } + } + + span.exit(); } async fn warren_mv(&self, response: &WarrenMvResponse) { @@ -392,9 +407,11 @@ impl AuthNotifier for NotifierDebugLogger { } async fn auth_warren_rm(&self, user: &User, response: &WarrenRmResponse) { + let results = response.results(); + let successes = results.iter().filter(|r| r.is_ok()).count(); + tracing::debug!( - "[Notifier] Deleted file {} from warren {} for authenticated user {}", - response.path(), + "[Notifier] Deleted {successes} file(s) from warren {} for authenticated user {}", response.warren().name(), user.id(), ); diff --git a/frontend/components/DirectoryEntry.vue b/frontend/components/DirectoryEntry.vue index 6fb0b3d..6fa7a59 100644 --- a/frontend/components/DirectoryEntry.vue +++ b/frontend/components/DirectoryEntry.vue @@ -6,7 +6,6 @@ import { ContextMenuItem, ContextMenuSeparator, } from '@/components/ui/context-menu'; -import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens'; import type { DirectoryEntry } from '#shared/types'; const warrenStore = useWarrenStore(); @@ -26,9 +25,9 @@ const { const emit = defineEmits<{ 'entry-click': [entry: DirectoryEntry, event: MouseEvent]; 'entry-download': [entry: DirectoryEntry]; + 'entry-delete': [entry: DirectoryEntry, force: boolean]; }>(); -const deleting = ref(false); const isCopied = computed( () => warrenStore.current != null && @@ -39,32 +38,11 @@ const isCopied = computed( ); const isSelected = computed(() => warrenStore.isSelected(entry)); -async function submitDelete(force: boolean = false) { - if (warrenStore.current == null) { - return; - } - - deleting.value = true; - - if (entry.fileType === 'directory') { - await deleteWarrenDirectory( - warrenStore.current.warrenId, - warrenStore.current.path, - entry.name, - force - ); - } else { - await deleteWarrenFile( - warrenStore.current.warrenId, - warrenStore.current.path, - entry.name - ); - } - - deleting.value = false; +function onDelete(force: boolean = false) { + emit('entry-delete', entry, force); } -async function openRenameDialog() { +function openRenameDialog() { renameDialog.openDialog(entry); } @@ -183,15 +161,14 @@ function onClearCopy() { Delete (); @@ -35,6 +36,10 @@ function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) { function onEntryDownload(entry: DirectoryEntry) { emit('entry-download', entry); } + +function onEntryDelete(entry: DirectoryEntry, force: boolean) { + emit('entry-delete', entry, force); +}