rename directory entries

This commit is contained in:
2025-07-16 06:23:24 +02:00
parent b121c27b37
commit a2cb58867c
16 changed files with 389 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ use crate::domain::warren::models::file::{
AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError, AbsoluteFilePath, CreateDirectoryError, CreateDirectoryRequest, CreateFileError,
CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError, CreateFileRequest, DeleteDirectoryError, DeleteDirectoryRequest, DeleteFileError,
DeleteFileRequest, FileName, FilePath, ListFilesError, ListFilesRequest, RelativeFilePath, DeleteFileRequest, FileName, FilePath, ListFilesError, ListFilesRequest, RelativeFilePath,
RenameEntryError, RenameEntryRequest,
}; };
use super::Warren; use super::Warren;
@@ -310,3 +311,53 @@ impl UploadFile {
&self.data &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<FetchWarrenRequest> 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),
}

View File

@@ -26,6 +26,9 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static {
fn record_warren_file_deletion_success(&self) -> impl Future<Output = ()> + Send; fn record_warren_file_deletion_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_file_deletion_failure(&self) -> impl Future<Output = ()> + Send; fn record_warren_file_deletion_failure(&self) -> impl Future<Output = ()> + Send;
fn record_warren_entry_rename_success(&self) -> impl Future<Output = ()> + Send;
fn record_warren_entry_rename_failure(&self) -> impl Future<Output = ()> + Send;
} }
pub trait FileSystemMetrics: Clone + Send + Sync + 'static { pub trait FileSystemMetrics: Clone + Send + Sync + 'static {

View File

@@ -16,8 +16,8 @@ use super::models::{
CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError, CreateWarrenDirectoryError, CreateWarrenDirectoryRequest, DeleteWarrenDirectoryError,
DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest, DeleteWarrenDirectoryRequest, DeleteWarrenFileError, DeleteWarrenFileRequest,
FetchWarrenError, FetchWarrenRequest, ListWarrenFilesError, ListWarrenFilesRequest, FetchWarrenError, FetchWarrenRequest, ListWarrenFilesError, ListWarrenFilesRequest,
ListWarrensError, ListWarrensRequest, UploadWarrenFilesError, UploadWarrenFilesRequest, ListWarrensError, ListWarrensRequest, RenameWarrenEntryError, RenameWarrenEntryRequest,
Warren, UploadWarrenFilesError, UploadWarrenFilesRequest, Warren,
}, },
}; };
@@ -55,6 +55,11 @@ pub trait WarrenService: Clone + Send + Sync + 'static {
&self, &self,
request: DeleteWarrenFileRequest, request: DeleteWarrenFileRequest,
) -> impl Future<Output = Result<FilePath, DeleteWarrenFileError>> + Send; ) -> impl Future<Output = Result<FilePath, DeleteWarrenFileError>> + Send;
fn rename_warren_entry(
&self,
request: RenameWarrenEntryRequest,
) -> impl Future<Output = Result<FilePath, RenameWarrenEntryError>> + Send;
} }
pub trait FileSystemService: Clone + Send + Sync + 'static { pub trait FileSystemService: Clone + Send + Sync + 'static {

View File

@@ -1,5 +1,5 @@
use crate::domain::warren::models::{ use crate::domain::warren::models::{
file::{File, FilePath}, file::{AbsoluteFilePath, File, FilePath},
warren::Warren, warren::Warren,
}; };
@@ -44,6 +44,13 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static {
warren: &Warren, warren: &Warren,
path: &FilePath, path: &FilePath,
) -> impl Future<Output = ()> + Send; ) -> impl Future<Output = ()> + Send;
fn warren_entry_renamed(
&self,
warren: &Warren,
old_path: &AbsoluteFilePath,
new_path: &FilePath,
) -> impl Future<Output = ()> + Send;
} }
pub trait FileSystemNotifier: Clone + Send + Sync + 'static { pub trait FileSystemNotifier: Clone + Send + Sync + 'static {

View File

@@ -3,7 +3,9 @@ use anyhow::Context;
use crate::domain::warren::{ use crate::domain::warren::{
models::{ models::{
file::{File, FilePath}, file::{File, FilePath},
warren::{ListWarrensError, ListWarrensRequest}, warren::{
ListWarrensError, ListWarrensRequest, RenameWarrenEntryError, RenameWarrenEntryRequest,
},
}, },
ports::FileSystemService, ports::FileSystemService,
}; };
@@ -230,4 +232,28 @@ where
result.map_err(Into::into) result.map_err(Into::into)
} }
async fn rename_warren_entry(
&self,
request: RenameWarrenEntryRequest,
) -> Result<FilePath, RenameWarrenEntryError> {
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)
}
} }

View File

@@ -4,12 +4,13 @@ mod delete_warren_file;
mod fetch_warren; mod fetch_warren;
mod list_warren_files; mod list_warren_files;
mod list_warrens; mod list_warrens;
mod rename_warren_entry;
mod upload_warren_files; mod upload_warren_files;
use axum::{ use axum::{
Router, Router,
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
routing::{delete, get, post}, routing::{delete, get, patch, post},
}; };
use crate::{domain::warren::ports::WarrenService, inbound::http::AppState}; 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_directory::delete_warren_directory;
use delete_warren_file::delete_warren_file; use delete_warren_file::delete_warren_file;
use rename_warren_entry::rename_warren_entry;
use upload_warren_files::upload_warren_files; use upload_warren_files::upload_warren_files;
pub fn routes<WS: WarrenService>() -> Router<AppState<WS>> { pub fn routes<WS: WarrenService>() -> Router<AppState<WS>> {
@@ -37,4 +39,5 @@ pub fn routes<WS: WarrenService>() -> Router<AppState<WS>> {
post(upload_warren_files).route_layer(DefaultBodyLimit::max(1073741824)), post(upload_warren_files).route_layer(DefaultBodyLimit::max(1073741824)),
) )
.route("/files/file", delete(delete_warren_file)) .route("/files/file", delete(delete_warren_file))
.route("/files/rename", patch(rename_warren_entry))
} }

View File

@@ -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<RenameWarrenEntryRequest, ParseRenameWarrenEntryHttpRequestError> {
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<ParseRenameWarrenEntryHttpRequestError> 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<RenameWarrenEntryError> for ApiError {
fn from(_: RenameWarrenEntryError) -> Self {
ApiError::InternalServerError("Internal server error".to_string())
}
}
pub async fn rename_warren_entry<WS: WarrenService>(
State(state): State<AppState<WS>>,
Json(request): Json<RenameWarrenEntryHttpRequestBody>,
) -> Result<ApiSuccess<()>, 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)
}

View File

@@ -1,6 +1,6 @@
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use anyhow::{Context, anyhow}; use anyhow::{Context, anyhow, bail};
use tokio::{fs, io::AsyncWriteExt as _}; use tokio::{fs, io::AsyncWriteExt as _};
use crate::domain::warren::{ use crate::domain::warren::{
@@ -156,6 +156,10 @@ impl FileSystem {
FilePath::new(&c)? FilePath::new(&c)?
}; };
if fs::try_exists(&new_path).await? {
bail!("File already exists");
}
fs::rename(current_path, &new_path).await?; fs::rename(current_path, &new_path).await?;
Ok(new_path) Ok(new_path)

View File

@@ -70,6 +70,13 @@ impl WarrenMetrics for MetricsDebugLogger {
async fn record_warren_file_deletion_failure(&self) { async fn record_warren_file_deletion_failure(&self) {
log::debug!("[Metrics] Warren file deletion failed"); 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 { impl FileSystemMetrics for MetricsDebugLogger {

View File

@@ -71,6 +71,20 @@ impl WarrenNotifier for NotifierDebugLogger {
warren.name(), 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 { impl FileSystemNotifier for NotifierDebugLogger {

View File

@@ -4,13 +4,14 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
ContextMenuContent, ContextMenuContent,
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator,
} from '@/components/ui/context-menu'; } from '@/components/ui/context-menu';
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens'; import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens';
import type { DirectoryEntry } from '~/types'; import type { DirectoryEntry } from '~/types';
import { buttonVariants } from '@/components/ui/button';
const route = useRoute(); const route = useRoute();
const warrenRoute = useWarrenRoute(); const warrenRoute = useWarrenRoute();
const renameDialog = useRenameDirectoryDialog();
const { entry, disabled } = defineProps<{ const { entry, disabled } = defineProps<{
entry: DirectoryEntry; entry: DirectoryEntry;
@@ -30,6 +31,10 @@ async function submitDelete(force: boolean = false) {
deleting.value = false; deleting.value = false;
} }
async function openRenameDialog() {
renameDialog.openDialog(entry);
}
</script> </script>
<template> <template>
@@ -62,6 +67,13 @@ async function submitDelete(force: boolean = false) {
</NuxtLink> </NuxtLink>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem @select="openRenameDialog">
<Icon name="lucide:pencil" />
Rename
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem @select="() => submitDelete(false)"> <ContextMenuItem @select="() => submitDelete(false)">
<Icon name="lucide:trash-2" /> <Icon name="lucide:trash-2" />
Delete Delete

View File

@@ -14,24 +14,17 @@ const warrenRoute = useWarrenRoute();
const dialog = useCreateDirectoryDialog(); const dialog = useCreateDirectoryDialog();
const creating = ref(false); const creating = ref(false);
const directoryName = ref(''); const directoryNameValid = computed(() => dialog.value.trim().length > 0);
const directoryNameValid = computed(
() => directoryName.value.trim().length > 0
);
async function submit() { async function submit() {
creating.value = true; creating.value = true;
const { success } = await createDirectory( const { success } = await createDirectory(warrenRoute.value, dialog.value);
warrenRoute.value,
directoryName.value
);
creating.value = false; creating.value = false;
if (success) { if (success) {
directoryName.value = ''; dialog.reset();
dialog.open = false;
} }
} }
</script> </script>
@@ -50,7 +43,7 @@ async function submit() {
</DialogHeader> </DialogHeader>
<Input <Input
v-model="directoryName" v-model="dialog.value"
type="text" type="text"
name="directory-name" name="directory-name"
placeholder="my-awesome-directory" placeholder="my-awesome-directory"

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { renameWarrenEntry } from '~/lib/api/warrens';
const warrenRoute = useWarrenRoute();
const dialog = useRenameDirectoryDialog();
const renaming = ref(false);
const directoryNameValid = computed(() => dialog.value.trim().length > 0);
async function submit() {
if (dialog.entry == null) {
return;
}
renaming.value = true;
const { success } = await renameWarrenEntry(
warrenRoute.value,
dialog.entry.name,
dialog.value
);
renaming.value = false;
if (success) {
dialog.reset();
}
}
</script>
<template>
<Dialog v-model:open="dialog.open">
<DialogContent>
<DialogHeader>
<DialogTitle>Rename your directory</DialogTitle>
<DialogDescription
>Give your directory a new memorable name</DialogDescription
>
</DialogHeader>
<Input
v-model="dialog.value"
type="text"
name="directory-name"
placeholder="my-awesome-directory"
aria-required="true"
autocomplete="off"
required
/>
<DialogFooter>
<Button
:disabled="!directoryNameValid || renaming"
@click="submit"
>Rename</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -256,3 +256,49 @@ export async function uploadToWarren(
return { success: true }; return { success: true };
} }
export async function renameWarrenEntry(
path: string,
currentName: string,
newName: string
): Promise<{ success: boolean }> {
// eslint-disable-next-line prefer-const
let [warrenId, rest] = splitOnce(path, '/');
if (rest == null) {
rest = '/';
} else {
rest = '/' + rest + '/';
}
rest += currentName;
const { status } = await useFetch(getApiUrl(`warrens/files/rename`), {
method: 'PATCH',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
warrenId,
path: rest,
newName,
}),
});
if (status.value !== 'success') {
toast.error('Rename', {
id: 'RENAME_FILE_TOAST',
description: `Failed to rename ${currentName}`,
});
return { success: false };
}
await refreshNuxtData('current-directory');
toast.success('Rename', {
id: 'RENAME_FILE_TOAST',
description: `Successfully renamed ${currentName} to ${newName}`,
});
return { success: true };
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue'; import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue';
import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
import { getWarrenDirectory } from '~/lib/api/warrens'; import { getWarrenDirectory } from '~/lib/api/warrens';
const entries = useAsyncData('current-directory', () => const entries = useAsyncData('current-directory', () =>
@@ -8,7 +9,10 @@ const entries = useAsyncData('current-directory', () =>
</script> </script>
<template> <template>
<DirectoryListContextMenu class="w-full grow"> <div>
<DirectoryList v-if="entries != null" :entries="entries" /> <DirectoryListContextMenu class="w-full grow">
</DirectoryListContextMenu> <DirectoryList v-if="entries != null" :entries="entries" />
</DirectoryListContextMenu>
<RenameEntryDialog />
</div>
</template> </template>

View File

@@ -1,4 +1,5 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import type { DirectoryEntry } from '~/types';
import type { Warren } from '~/types/warrens'; import type { Warren } from '~/types/warrens';
export const useWarrenStore = defineStore< export const useWarrenStore = defineStore<
@@ -19,10 +20,35 @@ export const useWarrenRoute = () =>
export const useCreateDirectoryDialog = defineStore('create_directory_dialog', { export const useCreateDirectoryDialog = defineStore('create_directory_dialog', {
state: () => ({ state: () => ({
open: false, open: false,
value: '',
}), }),
actions: { actions: {
openDialog() { openDialog() {
this.open = true; this.open = true;
}, },
reset() {
this.open = false;
this.value = '';
},
},
});
export const useRenameDirectoryDialog = defineStore('rename_directory_dialog', {
state: () => ({
open: false,
entry: null as DirectoryEntry | null,
value: '',
}),
actions: {
openDialog(entry: DirectoryEntry) {
this.entry = entry;
this.value = entry.name;
this.open = true;
},
reset() {
this.open = false;
this.entry = null;
this.value = '';
},
}, },
}); });