From a2cb58867c4c525fc6dcd37f3b8c840aa3eea552 Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Wed, 16 Jul 2025 06:23:24 +0200 Subject: [PATCH] rename directory entries --- .../domain/warren/models/warren/requests.rs | 51 ++++++++++ .../src/lib/domain/warren/ports/metrics.rs | 3 + backend/src/lib/domain/warren/ports/mod.rs | 9 +- .../src/lib/domain/warren/ports/notifier.rs | 9 +- .../src/lib/domain/warren/service/warren.rs | 28 +++++- .../lib/inbound/http/handlers/warrens/mod.rs | 5 +- .../handlers/warrens/rename_warren_entry.rs | 99 +++++++++++++++++++ backend/src/lib/outbound/file_system.rs | 6 +- .../src/lib/outbound/metrics_debug_logger.rs | 7 ++ .../src/lib/outbound/notifier_debug_logger.rs | 14 +++ frontend/components/DirectoryEntry.vue | 14 ++- .../actions/CreateDirectoryDialog.vue | 15 +-- .../components/actions/RenameEntryDialog.vue | 68 +++++++++++++ frontend/lib/api/warrens.ts | 46 +++++++++ frontend/pages/warrens/[...path].vue | 10 +- frontend/stores/index.ts | 26 +++++ 16 files changed, 389 insertions(+), 21 deletions(-) create mode 100644 backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs create mode 100644 frontend/components/actions/RenameEntryDialog.vue diff --git a/backend/src/lib/domain/warren/models/warren/requests.rs b/backend/src/lib/domain/warren/models/warren/requests.rs index a3e274a..23c0c67 100644 --- a/backend/src/lib/domain/warren/models/warren/requests.rs +++ b/backend/src/lib/domain/warren/models/warren/requests.rs @@ -5,6 +5,7 @@ use crate::domain::warren::models::file::{ AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, DeleteFileRequest, FileName, FilePath, ListFilesError, ListFilesRequest, RelativeFilePath, + RenameEntryError, RenameEntryRequest, }; use super::Warren; @@ -310,3 +311,53 @@ impl UploadFile { &self.data } } + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RenameWarrenEntryRequest { + warren_id: Uuid, + path: AbsoluteFilePath, + new_name: FileName, +} + +impl RenameWarrenEntryRequest { + pub fn new(warren_id: Uuid, path: AbsoluteFilePath, new_name: FileName) -> Self { + Self { + warren_id, + path, + new_name, + } + } + + pub fn warren_id(&self) -> &Uuid { + &self.warren_id + } + + pub fn path(&self) -> &AbsoluteFilePath { + &self.path + } + + pub fn new_name(&self) -> &FileName { + &self.new_name + } + + pub fn to_fs_request(self, warren: &Warren) -> RenameEntryRequest { + let path = warren.path().clone().join(&self.path.to_relative()); + RenameEntryRequest::new(path, self.new_name) + } +} + +impl Into for &RenameWarrenEntryRequest { + fn into(self) -> FetchWarrenRequest { + FetchWarrenRequest::new(self.warren_id) + } +} + +#[derive(Debug, Error)] +pub enum RenameWarrenEntryError { + #[error(transparent)] + Fetch(#[from] FetchWarrenError), + #[error(transparent)] + Rename(#[from] RenameEntryError), + #[error(transparent)] + Unknown(#[from] anyhow::Error), +} diff --git a/backend/src/lib/domain/warren/ports/metrics.rs b/backend/src/lib/domain/warren/ports/metrics.rs index 76e3a93..94df44f 100644 --- a/backend/src/lib/domain/warren/ports/metrics.rs +++ b/backend/src/lib/domain/warren/ports/metrics.rs @@ -26,6 +26,9 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static { fn record_warren_file_deletion_success(&self) -> impl Future + Send; fn record_warren_file_deletion_failure(&self) -> impl Future + Send; + + fn record_warren_entry_rename_success(&self) -> impl Future + Send; + fn record_warren_entry_rename_failure(&self) -> impl Future + Send; } pub trait FileSystemMetrics: Clone + Send + Sync + 'static { diff --git a/backend/src/lib/domain/warren/ports/mod.rs b/backend/src/lib/domain/warren/ports/mod.rs index 7b79fba..bd4e97d 100644 --- a/backend/src/lib/domain/warren/ports/mod.rs +++ b/backend/src/lib/domain/warren/ports/mod.rs @@ -16,8 +16,8 @@ use super::models::{ CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError, DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest, FetchWarrenError, FetchWarrenRequest, ListWarrenFilesError, ListWarrenFilesRequest, - ListWarrensError, ListWarrensRequest, UploadWarrenFilesError, UploadWarrenFilesRequest, - Warren, + ListWarrensError, ListWarrensRequest, RenameWarrenEntryError, RenameWarrenEntryRequest, + UploadWarrenFilesError, UploadWarrenFilesRequest, Warren, }, }; @@ -55,6 +55,11 @@ pub trait WarrenService: Clone + Send + Sync + 'static { &self, request: DeleteWarrenFileRequest, ) -> impl Future> + Send; + + fn rename_warren_entry( + &self, + request: RenameWarrenEntryRequest, + ) -> impl Future> + Send; } pub trait FileSystemService: Clone + Send + Sync + 'static { diff --git a/backend/src/lib/domain/warren/ports/notifier.rs b/backend/src/lib/domain/warren/ports/notifier.rs index c790a83..e6e623e 100644 --- a/backend/src/lib/domain/warren/ports/notifier.rs +++ b/backend/src/lib/domain/warren/ports/notifier.rs @@ -1,5 +1,5 @@ use crate::domain::warren::models::{ - file::{File, FilePath}, + file::{AbsoluteFilePath, File, FilePath}, warren::Warren, }; @@ -44,6 +44,13 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static { warren: &Warren, path: &FilePath, ) -> impl Future + Send; + + fn warren_entry_renamed( + &self, + warren: &Warren, + old_path: &AbsoluteFilePath, + new_path: &FilePath, + ) -> impl Future + Send; } pub trait FileSystemNotifier: Clone + Send + Sync + 'static { diff --git a/backend/src/lib/domain/warren/service/warren.rs b/backend/src/lib/domain/warren/service/warren.rs index 505d027..268b5b2 100644 --- a/backend/src/lib/domain/warren/service/warren.rs +++ b/backend/src/lib/domain/warren/service/warren.rs @@ -3,7 +3,9 @@ use anyhow::Context; use crate::domain::warren::{ models::{ file::{File, FilePath}, - warren::{ListWarrensError, ListWarrensRequest}, + warren::{ + ListWarrensError, ListWarrensRequest, RenameWarrenEntryError, RenameWarrenEntryRequest, + }, }, ports::FileSystemService, }; @@ -230,4 +232,28 @@ where result.map_err(Into::into) } + + async fn rename_warren_entry( + &self, + request: RenameWarrenEntryRequest, + ) -> Result { + let warren = self.repository.fetch_warren((&request).into()).await?; + + let old_path = request.path().clone(); + let result = self + .fs_service + .rename_entry(request.to_fs_request(&warren)) + .await; + + if let Ok(new_path) = result.as_ref() { + self.metrics.record_warren_entry_rename_success().await; + self.notifier + .warren_entry_renamed(&warren, &old_path, new_path) + .await; + } else { + self.metrics.record_warren_entry_rename_failure().await; + } + + result.map_err(Into::into) + } } diff --git a/backend/src/lib/inbound/http/handlers/warrens/mod.rs b/backend/src/lib/inbound/http/handlers/warrens/mod.rs index 936c8da..d49efc5 100644 --- a/backend/src/lib/inbound/http/handlers/warrens/mod.rs +++ b/backend/src/lib/inbound/http/handlers/warrens/mod.rs @@ -4,12 +4,13 @@ mod delete_warren_file; mod fetch_warren; mod list_warren_files; mod list_warrens; +mod rename_warren_entry; mod upload_warren_files; use axum::{ Router, extract::DefaultBodyLimit, - routing::{delete, get, post}, + routing::{delete, get, patch, post}, }; use crate::{domain::warren::ports::WarrenService, inbound::http::AppState}; @@ -22,6 +23,7 @@ use create_warren_directory::create_warren_directory; use delete_warren_directory::delete_warren_directory; use delete_warren_file::delete_warren_file; +use rename_warren_entry::rename_warren_entry; use upload_warren_files::upload_warren_files; pub fn routes() -> Router> { @@ -37,4 +39,5 @@ pub fn routes() -> Router> { post(upload_warren_files).route_layer(DefaultBodyLimit::max(1073741824)), ) .route("/files/file", delete(delete_warren_file)) + .route("/files/rename", patch(rename_warren_entry)) } diff --git a/backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs b/backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs new file mode 100644 index 0000000..b7b5ffe --- /dev/null +++ b/backend/src/lib/inbound/http/handlers/warrens/rename_warren_entry.rs @@ -0,0 +1,99 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + domain::warren::{ + models::{ + file::{ + AbsoluteFilePath, AbsoluteFilePathError, FileName, FileNameError, FilePath, + FilePathError, + }, + warren::{RenameWarrenEntryError, RenameWarrenEntryRequest}, + }, + ports::WarrenService, + }, + inbound::http::{ + AppState, + responses::{ApiError, ApiSuccess}, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameWarrenEntryHttpRequestBody { + warren_id: Uuid, + path: String, + new_name: String, +} + +#[derive(Debug, Clone, Error)] +pub enum ParseRenameWarrenEntryHttpRequestError { + #[error(transparent)] + FilePath(#[from] FilePathError), + #[error(transparent)] + AbsoluteFilePath(#[from] AbsoluteFilePathError), + #[error(transparent)] + FileName(#[from] FileNameError), +} + +impl RenameWarrenEntryHttpRequestBody { + fn try_into_domain( + self, + ) -> Result { + let path: AbsoluteFilePath = FilePath::new(&self.path)?.try_into()?; + let new_name = FileName::new(&self.new_name)?; + + Ok(RenameWarrenEntryRequest::new( + self.warren_id, + path, + new_name, + )) + } +} + +impl From for ApiError { + fn from(value: ParseRenameWarrenEntryHttpRequestError) -> Self { + match value { + ParseRenameWarrenEntryHttpRequestError::FilePath(err) => match err { + FilePathError::InvalidPath => { + ApiError::BadRequest("The file path must be valid".to_string()) + } + }, + ParseRenameWarrenEntryHttpRequestError::AbsoluteFilePath(err) => match err { + AbsoluteFilePathError::NotAbsolute => { + ApiError::BadRequest("The file path must be absolute".to_string()) + } + }, + ParseRenameWarrenEntryHttpRequestError::FileName(err) => match err { + FileNameError::Slash => { + ApiError::BadRequest("The new name must not include a slash".to_string()) + } + FileNameError::Empty => { + ApiError::BadRequest("The new name must not be empty".to_string()) + } + }, + } + } +} + +impl From for ApiError { + fn from(_: RenameWarrenEntryError) -> Self { + ApiError::InternalServerError("Internal server error".to_string()) + } +} + +pub async fn rename_warren_entry( + State(state): State>, + Json(request): Json, +) -> Result, ApiError> { + let domain_request = request.try_into_domain()?; + + state + .warren_service + .rename_warren_entry(domain_request) + .await + .map(|_| ApiSuccess::new(StatusCode::OK, ())) + .map_err(ApiError::from) +} diff --git a/backend/src/lib/outbound/file_system.rs b/backend/src/lib/outbound/file_system.rs index 8a793ad..4224589 100644 --- a/backend/src/lib/outbound/file_system.rs +++ b/backend/src/lib/outbound/file_system.rs @@ -1,6 +1,6 @@ use std::time::UNIX_EPOCH; -use anyhow::{Context, anyhow}; +use anyhow::{Context, anyhow, bail}; use tokio::{fs, io::AsyncWriteExt as _}; use crate::domain::warren::{ @@ -156,6 +156,10 @@ impl FileSystem { FilePath::new(&c)? }; + if fs::try_exists(&new_path).await? { + bail!("File already exists"); + } + fs::rename(current_path, &new_path).await?; Ok(new_path) diff --git a/backend/src/lib/outbound/metrics_debug_logger.rs b/backend/src/lib/outbound/metrics_debug_logger.rs index 35439d8..48eecda 100644 --- a/backend/src/lib/outbound/metrics_debug_logger.rs +++ b/backend/src/lib/outbound/metrics_debug_logger.rs @@ -70,6 +70,13 @@ impl WarrenMetrics for MetricsDebugLogger { async fn record_warren_file_deletion_failure(&self) { log::debug!("[Metrics] Warren file deletion failed"); } + + async fn record_warren_entry_rename_success(&self) { + log::debug!("[Metrics] Warren entry rename succeeded"); + } + async fn record_warren_entry_rename_failure(&self) { + log::debug!("[Metrics] Warren entry rename failed"); + } } impl FileSystemMetrics for MetricsDebugLogger { diff --git a/backend/src/lib/outbound/notifier_debug_logger.rs b/backend/src/lib/outbound/notifier_debug_logger.rs index 5b50a1b..60a6ea5 100644 --- a/backend/src/lib/outbound/notifier_debug_logger.rs +++ b/backend/src/lib/outbound/notifier_debug_logger.rs @@ -71,6 +71,20 @@ impl WarrenNotifier for NotifierDebugLogger { warren.name(), ); } + + async fn warren_entry_renamed( + &self, + warren: &Warren, + old_path: &crate::domain::warren::models::file::AbsoluteFilePath, + new_path: &FilePath, + ) { + log::debug!( + "[Notifier] Renamed file {} to {} in warren {}", + old_path, + new_path, + warren.name(), + ); + } } impl FileSystemNotifier for NotifierDebugLogger { diff --git a/frontend/components/DirectoryEntry.vue b/frontend/components/DirectoryEntry.vue index 674c757..c813989 100644 --- a/frontend/components/DirectoryEntry.vue +++ b/frontend/components/DirectoryEntry.vue @@ -4,13 +4,14 @@ import { ContextMenuTrigger, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, } from '@/components/ui/context-menu'; import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens'; import type { DirectoryEntry } from '~/types'; -import { buttonVariants } from '@/components/ui/button'; const route = useRoute(); const warrenRoute = useWarrenRoute(); +const renameDialog = useRenameDirectoryDialog(); const { entry, disabled } = defineProps<{ entry: DirectoryEntry; @@ -30,6 +31,10 @@ async function submitDelete(force: boolean = false) { deleting.value = false; } + +async function openRenameDialog() { + renameDialog.openDialog(entry); +}