diff --git a/backend/src/lib/domain/oidc/service.rs b/backend/src/lib/domain/oidc/service.rs index d2ae5c1..2cf3254 100644 --- a/backend/src/lib/domain/oidc/service.rs +++ b/backend/src/lib/domain/oidc/service.rs @@ -33,6 +33,24 @@ where } } +#[derive(Debug, Clone)] +pub struct NoopService {} +impl OidcService for NoopService { + async fn get_redirect( + &self, + _: GetRedirectRequest, + ) -> Result { + unimplemented!() + } + + async fn get_user_info( + &self, + _: GetUserInfoRequest, + ) -> Result { + unimplemented!() + } +} + impl OidcService for Service where R: OidcRepository, diff --git a/backend/src/lib/domain/warren/models/file/requests/mv.rs b/backend/src/lib/domain/warren/models/file/requests/mv.rs index 46c2fa2..d676d34 100644 --- a/backend/src/lib/domain/warren/models/file/requests/mv.rs +++ b/backend/src/lib/domain/warren/models/file/requests/mv.rs @@ -2,36 +2,38 @@ use thiserror::Error; use crate::domain::warren::models::file::AbsoluteFilePath; +use super::AbsoluteFilePathList; + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct MvRequest { - path: AbsoluteFilePath, + paths: AbsoluteFilePathList, target_path: AbsoluteFilePath, } impl MvRequest { - pub fn new(path: AbsoluteFilePath, target_path: AbsoluteFilePath) -> Self { - Self { path, target_path } + pub fn new(paths: AbsoluteFilePathList, target_path: AbsoluteFilePath) -> Self { + Self { paths, target_path } } - pub fn path(&self) -> &AbsoluteFilePath { - &self.path + pub fn paths(&self) -> &AbsoluteFilePathList { + &self.paths } pub fn target_path(&self) -> &AbsoluteFilePath { &self.target_path } - pub fn unpack(self) -> (AbsoluteFilePath, AbsoluteFilePath) { - (self.path, self.target_path) + pub fn unpack(self) -> (AbsoluteFilePathList, AbsoluteFilePath) { + (self.paths, self.target_path) } } #[derive(Debug, Error)] pub enum MvError { #[error("The path does not exist")] - NotFound, + NotFound(AbsoluteFilePath), #[error("The target path already exists")] - AlreadyExists, + AlreadyExists(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 12f0a11..1aee2ed 100644 --- a/backend/src/lib/domain/warren/models/warren/requests.rs +++ b/backend/src/lib/domain/warren/models/warren/requests.rs @@ -422,11 +422,16 @@ impl WarrenMvRequest { } pub fn build_fs_request(self, warren: &Warren) -> MvRequest { - let (base_path, base_target_path) = self.base.unpack(); - let path = warren.path().clone().join(&base_path.to_relative()); + let (mut base_paths, base_target_path) = self.base.unpack(); + let target_path = warren.path().clone().join(&base_target_path.to_relative()); - MvRequest::new(path, target_path) + base_paths + .paths_mut() + .into_iter() + .for_each(|path| *path = warren.path.clone().join(&path.clone().to_relative())); + + MvRequest::new(base_paths, target_path) } } @@ -442,32 +447,26 @@ impl Into for &WarrenMvRequest { } } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug)] pub struct WarrenMvResponse { warren: Warren, - old_path: AbsoluteFilePath, - path: AbsoluteFilePath, + results: Vec>, } impl WarrenMvResponse { - pub fn new(warren: Warren, old_path: AbsoluteFilePath, path: AbsoluteFilePath) -> Self { - Self { - warren, - old_path, - path, - } + pub fn new( + warren: Warren, + results: Vec>, + ) -> Self { + Self { warren, results } } pub fn warren(&self) -> &Warren { &self.warren } - pub fn old_path(&self) -> &AbsoluteFilePath { - &self.old_path - } - - pub fn path(&self) -> &AbsoluteFilePath { - &self.path + pub fn results(&self) -> &Vec> { + &self.results } } @@ -476,8 +475,6 @@ pub enum WarrenMvError { #[error(transparent)] FetchWarren(#[from] FetchWarrenError), #[error(transparent)] - FileSystem(#[from] MvError), - #[error(transparent)] Unknown(#[from] anyhow::Error), } diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 3916143..8edf098 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -149,7 +149,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static { &self, request: RmRequest, ) -> impl Future>> + Send; - fn mv(&self, request: MvRequest) -> impl Future> + Send; + fn mv( + &self, + request: MvRequest, + ) -> impl Future>> + Send; fn save( &self, request: SaveRequest, diff --git a/backend/src/lib/domain/warren/ports/repository.rs b/backend/src/lib/domain/warren/ports/repository.rs index 02ffb99..59b3c1c 100644 --- a/backend/src/lib/domain/warren/ports/repository.rs +++ b/backend/src/lib/domain/warren/ports/repository.rs @@ -102,7 +102,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static { &self, request: RmRequest, ) -> impl Future>> + Send; - fn mv(&self, request: MvRequest) -> impl Future> + Send; + fn mv( + &self, + request: MvRequest, + ) -> impl Future>> + Send; fn save( &self, request: SaveRequest, diff --git a/backend/src/lib/domain/warren/service/auth.rs b/backend/src/lib/domain/warren/service/auth.rs index 5b3d419..d2c8175 100644 --- a/backend/src/lib/domain/warren/service/auth.rs +++ b/backend/src/lib/domain/warren/service/auth.rs @@ -133,6 +133,10 @@ where oidc, } } + + pub fn oidc(&self) -> Option<&OIDC> { + self.oidc.as_ref() + } } impl AuthService for Service @@ -240,7 +244,7 @@ where &self, request: GetOidcRedirectRequest, ) -> Result { - let oidc = self.oidc.as_ref().ok_or(GetOidcRedirectError::Disabled)?; + let oidc = self.oidc().ok_or(GetOidcRedirectError::Disabled)?; oidc.get_redirect(request.into()) .await @@ -298,7 +302,7 @@ where &self, request: LoginUserOidcRequest, ) -> Result { - let oidc = self.oidc.as_ref().ok_or(LoginUserOidcError::Disabled)?; + let oidc = self.oidc().ok_or(LoginUserOidcError::Disabled)?; let user_info = oidc.get_user_info(request.into()).await?; diff --git a/backend/src/lib/domain/warren/service/file_system.rs b/backend/src/lib/domain/warren/service/file_system.rs index f539781..9cc2bdf 100644 --- a/backend/src/lib/domain/warren/service/file_system.rs +++ b/backend/src/lib/domain/warren/service/file_system.rs @@ -97,19 +97,22 @@ where results } - async fn mv(&self, request: MvRequest) -> Result<(), MvError> { - let old_path = request.path().clone(); - let new_path = request.target_path().clone(); - let result = self.repository.mv(request).await; + async fn mv( + &self, + request: MvRequest, + ) -> Vec> { + let results = self.repository.mv(request).await; - if result.is_ok() { - self.metrics.record_mv_success().await; - self.notifier.mv(&old_path, &new_path).await; - } else { - self.metrics.record_mv_failure().await; + for result in results.iter() { + if let Ok((old_path, new_path)) = result.as_ref() { + self.metrics.record_mv_success().await; + self.notifier.mv(old_path, new_path).await; + } else { + self.metrics.record_mv_failure().await; + } } - result + results } async fn save(&self, request: SaveRequest<'_>) -> Result { diff --git a/backend/src/lib/domain/warren/service/warren.rs b/backend/src/lib/domain/warren/service/warren.rs index dbe7769..f8682d1 100644 --- a/backend/src/lib/domain/warren/service/warren.rs +++ b/backend/src/lib/domain/warren/service/warren.rs @@ -269,26 +269,21 @@ where } async fn warren_mv(&self, request: WarrenMvRequest) -> 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_mv_failure().await; + return Err(e.into()); + } + }; - let old_path = request.base().path().clone(); - let new_path = request.base().target_path().clone(); let mv_request = request.build_fs_request(&warren); - let result = self - .fs_service - .mv(mv_request) - .await - .map(|_| WarrenMvResponse::new(warren, old_path, new_path)) - .map_err(Into::into); + let response = WarrenMvResponse::new(warren, self.fs_service.mv(mv_request).await); - if let Ok(response) = result.as_ref() { - self.metrics.record_warren_mv_success().await; - self.notifier.warren_mv(response).await; - } else { - self.metrics.record_warren_mv_failure().await; - } + self.metrics.record_warren_mv_success().await; + self.notifier.warren_mv(&response).await; - result + Ok(response) } async fn warren_touch( diff --git a/backend/src/lib/inbound/http/handlers/warrens/warren_mv.rs b/backend/src/lib/inbound/http/handlers/warrens/warren_mv.rs index 219cf43..73c36b6 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/warren_mv.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/warren_mv.rs @@ -7,7 +7,10 @@ use crate::{ domain::warren::{ models::{ auth_session::AuthRequest, - file::{AbsoluteFilePath, AbsoluteFilePathError, FilePath, FilePathError, MvRequest}, + file::{ + AbsoluteFilePath, AbsoluteFilePathError, AbsoluteFilePathList, + AbsoluteFilePathListError, FilePath, FilePathError, MvRequest, + }, warren::WarrenMvRequest, }, ports::{AuthService, WarrenService}, @@ -23,7 +26,7 @@ use crate::{ #[serde(rename_all = "camelCase")] pub struct MvWarrenEntryHttpRequestBody { warren_id: Uuid, - path: String, + paths: Vec, target_path: String, } @@ -33,16 +36,25 @@ pub enum ParseWarrenMvHttpRequestError { FilePath(#[from] FilePathError), #[error(transparent)] AbsoluteFilePath(#[from] AbsoluteFilePathError), + #[error(transparent)] + AbsoluteFilePathList(#[from] AbsoluteFilePathListError), } impl MvWarrenEntryHttpRequestBody { fn try_into_domain(self) -> Result { - let path: AbsoluteFilePath = FilePath::new(&self.path)?.try_into()?; + let mut paths = Vec::::new(); + + for path in self.paths.iter() { + paths.push(FilePath::new(path)?.try_into()?); + } + + let path_list = AbsoluteFilePathList::new(paths)?; + let target_path: AbsoluteFilePath = FilePath::new(&self.target_path)?.try_into()?; Ok(WarrenMvRequest::new( self.warren_id, - MvRequest::new(path, target_path), + MvRequest::new(path_list, target_path), )) } } @@ -52,12 +64,17 @@ impl From for ApiError { match value { ParseWarrenMvHttpRequestError::FilePath(err) => match err { FilePathError::InvalidPath => { - ApiError::BadRequest("The file path must be valid".to_string()) + Self::BadRequest("The file path must be valid".to_string()) } }, ParseWarrenMvHttpRequestError::AbsoluteFilePath(err) => match err { AbsoluteFilePathError::NotAbsolute => { - ApiError::BadRequest("The file path must be absolute".to_string()) + Self::BadRequest("The file path must be absolute".to_string()) + } + }, + ParseWarrenMvHttpRequestError::AbsoluteFilePathList(err) => match err { + AbsoluteFilePathListError::Empty => { + Self::BadRequest("You must provide at least 1 path".to_string()) } }, } diff --git a/backend/src/lib/outbound/file_system.rs b/backend/src/lib/outbound/file_system.rs index 66940cb..53784dc 100644 --- a/backend/src/lib/outbound/file_system.rs +++ b/backend/src/lib/outbound/file_system.rs @@ -302,19 +302,50 @@ impl FileSystem { } } - async fn mv(&self, path: &AbsoluteFilePath, target_path: &AbsoluteFilePath) -> io::Result<()> { - let current_path = self.get_target_path(path); - let target_path = self.get_target_path(target_path); + async fn mv( + &self, + path: &AbsoluteFilePath, + target_path: &AbsoluteFilePath, + ) -> io::Result<(AbsoluteFilePath, AbsoluteFilePath)> { + let mut target_path = target_path.clone(); - if !fs::try_exists(¤t_path).await? { + let current_fs_path = self.get_target_path(path); + let mut target_fs_path = self.get_target_path(&target_path); + + if !fs::try_exists(¤t_fs_path).await? { return Err(io::ErrorKind::NotFound.into()); } - if fs::try_exists(&target_path).await? { + if !fs::try_exists(&target_fs_path).await? { + return fs::rename(current_fs_path, target_fs_path) + .await + .map(|_| (path.clone(), target_path.clone())); + } + + let target_is_dir = fs::metadata(target_fs_path).await?.is_dir(); + + if !target_is_dir { return Err(io::ErrorKind::AlreadyExists.into()); } - fs::rename(current_path, &target_path).await + let name = { + let current_path = path.as_str(); + if let Some(last_slash_index) = current_path.rfind("/") + && last_slash_index > 0 + { + ¤t_path[last_slash_index + 1..] + } else { + return Err(io::ErrorKind::AlreadyExists.into()); + } + }; + + target_path = + target_path.join(&RelativeFilePath::new(FilePath::new(name).unwrap()).unwrap()); + target_fs_path = self.get_target_path(&target_path); + + fs::rename(current_fs_path, target_fs_path) + .await + .map(|_| (path.clone(), target_path.clone())) } async fn touch(&self, path: &AbsoluteFilePath) -> io::Result<()> { @@ -431,29 +462,42 @@ impl FileSystemRepository for FileSystem { }) } - let results: Vec> = join_all( + join_all( paths .into_iter() .map(|path| _rm(&self, path, force)) .collect::>(), ) - .await; - - results + .await } - async fn mv(&self, request: MvRequest) -> Result<(), MvError> { - self.mv(request.path(), request.target_path()) - .await - .map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => MvError::NotFound, - _ => anyhow!( - "Failed to move {} to {}: {e:?}", - request.path(), - request.target_path() - ) - .into(), + async fn mv( + &self, + request: MvRequest, + ) -> Vec> { + async fn _mv( + fs: &FileSystem, + path: AbsoluteFilePath, + target_path: &AbsoluteFilePath, + ) -> Result<(AbsoluteFilePath, AbsoluteFilePath), MvError> { + fs.mv(&path, target_path).await.map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => MvError::NotFound(path), + _ => MvError::Unknown( + anyhow!("Failed to move {} to {}: {e:?}", path, target_path).into(), + ), }) + } + + let (path_list, target_path) = request.unpack(); + let paths = Vec::::from(path_list); + + join_all( + paths + .into_iter() + .map(|path| _mv(&self, path, &target_path)) + .collect::>(), + ) + .await } async fn touch(&self, request: TouchRequest) -> Result<(), TouchError> { diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 143525b..2755645 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -110,12 +110,28 @@ impl WarrenNotifier for NotifierDebugLogger { } async fn warren_mv(&self, response: &WarrenMvResponse) { - tracing::debug!( - "[Notifier] Renamed file {} to {} in warren {}", - response.old_path(), - response.path(), - response.warren().name(), - ); + let span = tracing::debug_span!("warren_mv", "{}", response.warren().name()).entered(); + + let results = response.results(); + + for result in results { + match result.as_ref() { + Ok((old_path, new_path)) => { + tracing::debug!("Moved file {old_path} to {new_path}") + } + Err(e) => match e { + crate::domain::warren::models::file::MvError::NotFound(path) => { + tracing::debug!("File not found: {path}") + } + crate::domain::warren::models::file::MvError::AlreadyExists(path) => { + tracing::debug!("File already exists: {path}") + } + crate::domain::warren::models::file::MvError::Unknown(_) => (), + }, + } + } + + span.exit(); } async fn warren_touch(&self, warren: &Warren, path: &AbsoluteFilePath) { @@ -418,10 +434,11 @@ impl AuthNotifier for NotifierDebugLogger { } async fn auth_warren_mv(&self, user: &User, response: &WarrenMvResponse) { + let results = response.results(); + let successes = results.iter().filter(|r| r.is_ok()).count(); + tracing::debug!( - "[Notifier] Renamed file {} to {} in warren {} for authenticated user {}", - response.old_path(), - response.path(), + "[Notifier] Moved {successes} file(s) in warren {} for authenticated user {}", response.warren().name(), user.id(), ); diff --git a/frontend/lib/api/warrens.ts b/frontend/lib/api/warrens.ts index 47149ef..e8256f9 100644 --- a/frontend/lib/api/warrens.ts +++ b/frontend/lib/api/warrens.ts @@ -382,9 +382,9 @@ export async function fetchFileStream( }; } -export async function moveFile( +export async function moveFiles( warrenId: string, - currentPath: string, + currentPaths: string[], targetPath: string ): Promise<{ success: boolean }> { const { status } = await useFetch(getApiUrl(`warrens/files/mv`), { @@ -392,7 +392,7 @@ export async function moveFile( headers: getApiHeaders(), body: JSON.stringify({ warrenId, - path: currentPath, + paths: currentPaths, targetPath: targetPath, }), }); diff --git a/frontend/pages/share.vue b/frontend/pages/share.vue index 2213bf1..3466ee6 100644 --- a/frontend/pages/share.vue +++ b/frontend/pages/share.vue @@ -7,7 +7,6 @@ definePageMeta({ layout: 'share', }); -const selectionRect = useSelectionRect(); const warrenStore = useWarrenStore(); const route = useRoute(); diff --git a/frontend/utils/files.ts b/frontend/utils/files.ts index c945b08..d9ac6be 100644 --- a/frontend/utils/files.ts +++ b/frontend/utils/files.ts @@ -1,4 +1,4 @@ -import { copyFile, moveFile } from '~/lib/api/warrens'; +import { copyFile, moveFiles } from '~/lib/api/warrens'; import type { DirectoryEntry } from '~/shared/types'; export function joinPaths(path: string, ...other: string[]): string { @@ -25,7 +25,11 @@ export function onDirectoryEntryDrop( return async (e: DragEvent) => { const warrenStore = useWarrenStore(); - if (e.dataTransfer == null || warrenStore.current == null) { + if ( + e.dataTransfer == null || + warrenStore.current == null || + warrenStore.current.dir == null + ) { return; } @@ -39,23 +43,29 @@ export function onDirectoryEntryDrop( return; } - const currentPath = joinPaths(warrenStore.current.path, fileName); + const draggedEntry = warrenStore.current.dir.entries.find( + (e) => e.name === fileName + ); + if (draggedEntry == null) { + return; + } + + const targetPaths = getTargetsFromSelection( + draggedEntry, + warrenStore.selection + ).map((currentEntry) => + joinPaths(warrenStore.current!.path, currentEntry.name) + ); + let targetPath: string; if (isParent) { - targetPath = joinPaths( - getParentPath(warrenStore.current.path), - fileName - ); + targetPath = getParentPath(warrenStore.current.path); } else { - targetPath = joinPaths( - warrenStore.current.path, - entry.name, - fileName - ); + targetPath = joinPaths(warrenStore.current.path, entry.name); } - await moveFile(warrenStore.current.warrenId, currentPath, targetPath); + await moveFiles(warrenStore.current.warrenId, targetPaths, targetPath); }; }