move multiple files at once

This commit is contained in:
2025-09-05 01:22:40 +02:00
parent 13e91fdfbf
commit 49c59cbaea
14 changed files with 224 additions and 112 deletions

View File

@@ -33,6 +33,24 @@ where
} }
} }
#[derive(Debug, Clone)]
pub struct NoopService {}
impl OidcService for NoopService {
async fn get_redirect(
&self,
_: GetRedirectRequest,
) -> Result<GetRedirectResponse, GetRedirectError> {
unimplemented!()
}
async fn get_user_info(
&self,
_: GetUserInfoRequest,
) -> Result<GetUserInfoResponse, GetUserInfoError> {
unimplemented!()
}
}
impl<R, M, N> OidcService for Service<R, M, N> impl<R, M, N> OidcService for Service<R, M, N>
where where
R: OidcRepository, R: OidcRepository,

View File

@@ -2,36 +2,38 @@ use thiserror::Error;
use crate::domain::warren::models::file::AbsoluteFilePath; use crate::domain::warren::models::file::AbsoluteFilePath;
use super::AbsoluteFilePathList;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct MvRequest { pub struct MvRequest {
path: AbsoluteFilePath, paths: AbsoluteFilePathList,
target_path: AbsoluteFilePath, target_path: AbsoluteFilePath,
} }
impl MvRequest { impl MvRequest {
pub fn new(path: AbsoluteFilePath, target_path: AbsoluteFilePath) -> Self { pub fn new(paths: AbsoluteFilePathList, target_path: AbsoluteFilePath) -> Self {
Self { path, target_path } Self { paths, target_path }
} }
pub fn path(&self) -> &AbsoluteFilePath { pub fn paths(&self) -> &AbsoluteFilePathList {
&self.path &self.paths
} }
pub fn target_path(&self) -> &AbsoluteFilePath { pub fn target_path(&self) -> &AbsoluteFilePath {
&self.target_path &self.target_path
} }
pub fn unpack(self) -> (AbsoluteFilePath, AbsoluteFilePath) { pub fn unpack(self) -> (AbsoluteFilePathList, AbsoluteFilePath) {
(self.path, self.target_path) (self.paths, self.target_path)
} }
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum MvError { pub enum MvError {
#[error("The path does not exist")] #[error("The path does not exist")]
NotFound, NotFound(AbsoluteFilePath),
#[error("The target path already exists")] #[error("The target path already exists")]
AlreadyExists, AlreadyExists(AbsoluteFilePath),
#[error(transparent)] #[error(transparent)]
Unknown(#[from] anyhow::Error), Unknown(#[from] anyhow::Error),
} }

View File

@@ -422,11 +422,16 @@ impl WarrenMvRequest {
} }
pub fn build_fs_request(self, warren: &Warren) -> MvRequest { pub fn build_fs_request(self, warren: &Warren) -> MvRequest {
let (base_path, base_target_path) = self.base.unpack(); let (mut base_paths, base_target_path) = self.base.unpack();
let path = warren.path().clone().join(&base_path.to_relative());
let target_path = warren.path().clone().join(&base_target_path.to_relative()); 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<FetchWarrenRequest> for &WarrenMvRequest {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug)]
pub struct WarrenMvResponse { pub struct WarrenMvResponse {
warren: Warren, warren: Warren,
old_path: AbsoluteFilePath, results: Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>,
path: AbsoluteFilePath,
} }
impl WarrenMvResponse { impl WarrenMvResponse {
pub fn new(warren: Warren, old_path: AbsoluteFilePath, path: AbsoluteFilePath) -> Self { pub fn new(
Self { warren: Warren,
warren, results: Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>,
old_path, ) -> Self {
path, Self { warren, results }
}
} }
pub fn warren(&self) -> &Warren { pub fn warren(&self) -> &Warren {
&self.warren &self.warren
} }
pub fn old_path(&self) -> &AbsoluteFilePath { pub fn results(&self) -> &Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>> {
&self.old_path &self.results
}
pub fn path(&self) -> &AbsoluteFilePath {
&self.path
} }
} }
@@ -476,8 +475,6 @@ pub enum WarrenMvError {
#[error(transparent)] #[error(transparent)]
FetchWarren(#[from] FetchWarrenError), FetchWarren(#[from] FetchWarrenError),
#[error(transparent)] #[error(transparent)]
FileSystem(#[from] MvError),
#[error(transparent)]
Unknown(#[from] anyhow::Error), Unknown(#[from] anyhow::Error),
} }

View File

@@ -149,7 +149,10 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
&self, &self,
request: RmRequest, request: RmRequest,
) -> impl Future<Output = Vec<Result<AbsoluteFilePath, RmError>>> + Send; ) -> impl Future<Output = Vec<Result<AbsoluteFilePath, RmError>>> + Send;
fn mv(&self, request: MvRequest) -> impl Future<Output = Result<(), MvError>> + Send; fn mv(
&self,
request: MvRequest,
) -> impl Future<Output = Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>> + Send;
fn save( fn save(
&self, &self,
request: SaveRequest, request: SaveRequest,

View File

@@ -102,7 +102,10 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
&self, &self,
request: RmRequest, request: RmRequest,
) -> impl Future<Output = Vec<Result<AbsoluteFilePath, RmError>>> + Send; ) -> impl Future<Output = Vec<Result<AbsoluteFilePath, RmError>>> + Send;
fn mv(&self, request: MvRequest) -> impl Future<Output = Result<(), MvError>> + Send; fn mv(
&self,
request: MvRequest,
) -> impl Future<Output = Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>> + Send;
fn save( fn save(
&self, &self,
request: SaveRequest, request: SaveRequest,

View File

@@ -133,6 +133,10 @@ where
oidc, oidc,
} }
} }
pub fn oidc(&self) -> Option<&OIDC> {
self.oidc.as_ref()
}
} }
impl<R, M, N, OIDC> AuthService for Service<R, M, N, OIDC> impl<R, M, N, OIDC> AuthService for Service<R, M, N, OIDC>
@@ -240,7 +244,7 @@ where
&self, &self,
request: GetOidcRedirectRequest, request: GetOidcRedirectRequest,
) -> Result<GetOidcRedirectResponse, GetOidcRedirectError> { ) -> Result<GetOidcRedirectResponse, GetOidcRedirectError> {
let oidc = self.oidc.as_ref().ok_or(GetOidcRedirectError::Disabled)?; let oidc = self.oidc().ok_or(GetOidcRedirectError::Disabled)?;
oidc.get_redirect(request.into()) oidc.get_redirect(request.into())
.await .await
@@ -298,7 +302,7 @@ where
&self, &self,
request: LoginUserOidcRequest, request: LoginUserOidcRequest,
) -> Result<LoginUserOidcResponse, LoginUserOidcError> { ) -> Result<LoginUserOidcResponse, LoginUserOidcError> {
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?; let user_info = oidc.get_user_info(request.into()).await?;

View File

@@ -97,19 +97,22 @@ where
results results
} }
async fn mv(&self, request: MvRequest) -> Result<(), MvError> { async fn mv(
let old_path = request.path().clone(); &self,
let new_path = request.target_path().clone(); request: MvRequest,
let result = self.repository.mv(request).await; ) -> Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>> {
let results = self.repository.mv(request).await;
if result.is_ok() { for result in results.iter() {
if let Ok((old_path, new_path)) = result.as_ref() {
self.metrics.record_mv_success().await; self.metrics.record_mv_success().await;
self.notifier.mv(&old_path, &new_path).await; self.notifier.mv(old_path, new_path).await;
} else { } else {
self.metrics.record_mv_failure().await; self.metrics.record_mv_failure().await;
} }
}
result results
} }
async fn save(&self, request: SaveRequest<'_>) -> Result<SaveResponse, SaveError> { async fn save(&self, request: SaveRequest<'_>) -> Result<SaveResponse, SaveError> {

View File

@@ -269,26 +269,21 @@ where
} }
async fn warren_mv(&self, request: WarrenMvRequest) -> Result<WarrenMvResponse, WarrenMvError> { async fn warren_mv(&self, request: WarrenMvRequest) -> Result<WarrenMvResponse, WarrenMvError> {
let warren = self.repository.fetch_warren((&request).into()).await?; let warren = match self.repository.fetch_warren((&request).into()).await {
Ok(warren) => warren,
let old_path = request.base().path().clone(); Err(e) => {
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);
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_failure().await;
return Err(e.into());
} }
};
result let mv_request = request.build_fs_request(&warren);
let response = WarrenMvResponse::new(warren, self.fs_service.mv(mv_request).await);
self.metrics.record_warren_mv_success().await;
self.notifier.warren_mv(&response).await;
Ok(response)
} }
async fn warren_touch( async fn warren_touch(

View File

@@ -7,7 +7,10 @@ use crate::{
domain::warren::{ domain::warren::{
models::{ models::{
auth_session::AuthRequest, auth_session::AuthRequest,
file::{AbsoluteFilePath, AbsoluteFilePathError, FilePath, FilePathError, MvRequest}, file::{
AbsoluteFilePath, AbsoluteFilePathError, AbsoluteFilePathList,
AbsoluteFilePathListError, FilePath, FilePathError, MvRequest,
},
warren::WarrenMvRequest, warren::WarrenMvRequest,
}, },
ports::{AuthService, WarrenService}, ports::{AuthService, WarrenService},
@@ -23,7 +26,7 @@ use crate::{
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct MvWarrenEntryHttpRequestBody { pub struct MvWarrenEntryHttpRequestBody {
warren_id: Uuid, warren_id: Uuid,
path: String, paths: Vec<String>,
target_path: String, target_path: String,
} }
@@ -33,16 +36,25 @@ pub enum ParseWarrenMvHttpRequestError {
FilePath(#[from] FilePathError), FilePath(#[from] FilePathError),
#[error(transparent)] #[error(transparent)]
AbsoluteFilePath(#[from] AbsoluteFilePathError), AbsoluteFilePath(#[from] AbsoluteFilePathError),
#[error(transparent)]
AbsoluteFilePathList(#[from] AbsoluteFilePathListError),
} }
impl MvWarrenEntryHttpRequestBody { impl MvWarrenEntryHttpRequestBody {
fn try_into_domain(self) -> Result<WarrenMvRequest, ParseWarrenMvHttpRequestError> { fn try_into_domain(self) -> Result<WarrenMvRequest, ParseWarrenMvHttpRequestError> {
let path: AbsoluteFilePath = FilePath::new(&self.path)?.try_into()?; let mut paths = Vec::<AbsoluteFilePath>::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()?; let target_path: AbsoluteFilePath = FilePath::new(&self.target_path)?.try_into()?;
Ok(WarrenMvRequest::new( Ok(WarrenMvRequest::new(
self.warren_id, self.warren_id,
MvRequest::new(path, target_path), MvRequest::new(path_list, target_path),
)) ))
} }
} }
@@ -52,12 +64,17 @@ impl From<ParseWarrenMvHttpRequestError> for ApiError {
match value { match value {
ParseWarrenMvHttpRequestError::FilePath(err) => match err { ParseWarrenMvHttpRequestError::FilePath(err) => match err {
FilePathError::InvalidPath => { 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 { ParseWarrenMvHttpRequestError::AbsoluteFilePath(err) => match err {
AbsoluteFilePathError::NotAbsolute => { 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())
} }
}, },
} }

View File

@@ -302,19 +302,50 @@ impl FileSystem {
} }
} }
async fn mv(&self, path: &AbsoluteFilePath, target_path: &AbsoluteFilePath) -> io::Result<()> { async fn mv(
let current_path = self.get_target_path(path); &self,
let target_path = self.get_target_path(target_path); path: &AbsoluteFilePath,
target_path: &AbsoluteFilePath,
) -> io::Result<(AbsoluteFilePath, AbsoluteFilePath)> {
let mut target_path = target_path.clone();
if !fs::try_exists(&current_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(&current_fs_path).await? {
return Err(io::ErrorKind::NotFound.into()); 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()); 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
{
&current_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<()> { async fn touch(&self, path: &AbsoluteFilePath) -> io::Result<()> {
@@ -431,31 +462,44 @@ impl FileSystemRepository for FileSystem {
}) })
} }
let results: Vec<Result<AbsoluteFilePath, RmError>> = join_all( join_all(
paths paths
.into_iter() .into_iter()
.map(|path| _rm(&self, path, force)) .map(|path| _rm(&self, path, force))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.await; .await
results
} }
async fn mv(&self, request: MvRequest) -> Result<(), MvError> { async fn mv(
self.mv(request.path(), request.target_path()) &self,
.await request: MvRequest,
.map_err(|e| match e.kind() { ) -> Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>> {
std::io::ErrorKind::NotFound => MvError::NotFound, async fn _mv(
_ => anyhow!( fs: &FileSystem,
"Failed to move {} to {}: {e:?}", path: AbsoluteFilePath,
request.path(), target_path: &AbsoluteFilePath,
request.target_path() ) -> Result<(AbsoluteFilePath, AbsoluteFilePath), MvError> {
) fs.mv(&path, target_path).await.map_err(|e| match e.kind() {
.into(), 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::<AbsoluteFilePath>::from(path_list);
join_all(
paths
.into_iter()
.map(|path| _mv(&self, path, &target_path))
.collect::<Vec<_>>(),
)
.await
}
async fn touch(&self, request: TouchRequest) -> Result<(), TouchError> { async fn touch(&self, request: TouchRequest) -> Result<(), TouchError> {
self.touch(request.path()) self.touch(request.path())
.await .await

View File

@@ -110,12 +110,28 @@ impl WarrenNotifier for NotifierDebugLogger {
} }
async fn warren_mv(&self, response: &WarrenMvResponse) { async fn warren_mv(&self, response: &WarrenMvResponse) {
tracing::debug!( let span = tracing::debug_span!("warren_mv", "{}", response.warren().name()).entered();
"[Notifier] Renamed file {} to {} in warren {}",
response.old_path(), let results = response.results();
response.path(),
response.warren().name(), 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) { 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) { 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!( tracing::debug!(
"[Notifier] Renamed file {} to {} in warren {} for authenticated user {}", "[Notifier] Moved {successes} file(s) in warren {} for authenticated user {}",
response.old_path(),
response.path(),
response.warren().name(), response.warren().name(),
user.id(), user.id(),
); );

View File

@@ -382,9 +382,9 @@ export async function fetchFileStream(
}; };
} }
export async function moveFile( export async function moveFiles(
warrenId: string, warrenId: string,
currentPath: string, currentPaths: string[],
targetPath: string targetPath: string
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const { status } = await useFetch(getApiUrl(`warrens/files/mv`), { const { status } = await useFetch(getApiUrl(`warrens/files/mv`), {
@@ -392,7 +392,7 @@ export async function moveFile(
headers: getApiHeaders(), headers: getApiHeaders(),
body: JSON.stringify({ body: JSON.stringify({
warrenId, warrenId,
path: currentPath, paths: currentPaths,
targetPath: targetPath, targetPath: targetPath,
}), }),
}); });

View File

@@ -7,7 +7,6 @@ definePageMeta({
layout: 'share', layout: 'share',
}); });
const selectionRect = useSelectionRect();
const warrenStore = useWarrenStore(); const warrenStore = useWarrenStore();
const route = useRoute(); const route = useRoute();

View File

@@ -1,4 +1,4 @@
import { copyFile, moveFile } from '~/lib/api/warrens'; import { copyFile, moveFiles } from '~/lib/api/warrens';
import type { DirectoryEntry } from '~/shared/types'; import type { DirectoryEntry } from '~/shared/types';
export function joinPaths(path: string, ...other: string[]): string { export function joinPaths(path: string, ...other: string[]): string {
@@ -25,7 +25,11 @@ export function onDirectoryEntryDrop(
return async (e: DragEvent) => { return async (e: DragEvent) => {
const warrenStore = useWarrenStore(); const warrenStore = useWarrenStore();
if (e.dataTransfer == null || warrenStore.current == null) { if (
e.dataTransfer == null ||
warrenStore.current == null ||
warrenStore.current.dir == null
) {
return; return;
} }
@@ -39,23 +43,29 @@ export function onDirectoryEntryDrop(
return; 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; let targetPath: string;
if (isParent) { if (isParent) {
targetPath = joinPaths( targetPath = getParentPath(warrenStore.current.path);
getParentPath(warrenStore.current.path),
fileName
);
} else { } else {
targetPath = joinPaths( targetPath = joinPaths(warrenStore.current.path, entry.name);
warrenStore.current.path,
entry.name,
fileName
);
} }
await moveFile(warrenStore.current.warrenId, currentPath, targetPath); await moveFiles(warrenStore.current.warrenId, targetPaths, targetPath);
}; };
} }