diff --git a/components/object/list.tsx b/components/object/list.tsx index 0c751381..2521d23d 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -13,6 +13,7 @@ import { RiArrowLeftSLine, RiArrowRightSLine, RiEyeLine, + RiEdit2Line, } from "@remixicon/react" import { AlertDialog, @@ -26,6 +27,15 @@ import { } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { SearchInput } from "@/components/search-input" import { PageHeader } from "@/components/page-header" @@ -63,6 +73,12 @@ import { normalizeObjectZipDownloadUrl, type CreateObjectZipDownloadResponse, } from "@/lib/object-zip-download" +import { + buildRenamedObjectKey, + getObjectBaseName, + validateObjectRename, + type ObjectRenameValidation, +} from "@/lib/object-rename" import { TaskStatsButton } from "@/components/tasks/stats-button" import { useAddDeleteKeys, useAddDeleteFolder, useTasks } from "@/contexts/task-context" import type { ColumnDef } from "@tanstack/react-table" @@ -100,7 +116,7 @@ export function ObjectList({ const { t } = useTranslation() const message = useMessage() const api = useApi() - const { listObject, getSignedUrl } = useObject(bucket) + const { listObject, getSignedUrl, renameObject } = useObject(bucket) const { getBucketVersioning } = useBucket() const { canCapability } = usePermissions() const addDeleteKeys = useAddDeleteKeys() @@ -122,6 +138,10 @@ export function ObjectList({ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const [deleteDialogKeys, setDeleteDialogKeys] = React.useState([]) const [deleteAllVersions, setDeleteAllVersions] = React.useState(false) + const [renameDialogOpen, setRenameDialogOpen] = React.useState(false) + const [renameSourceKey, setRenameSourceKey] = React.useState("") + const [renameName, setRenameName] = React.useState("") + const [renameSubmitting, setRenameSubmitting] = React.useState(false) const prefix = decodeURIComponent(path) const resolvedPageSize = normalizeObjectListPageSize(storedPageSize) @@ -333,6 +353,22 @@ export function ObjectList({ [message, t, getSignedUrl], ) + const getRenameValidationMessage = React.useCallback( + (validation: ObjectRenameValidation) => { + switch (validation) { + case "empty": + return t("Object name is required") + case "containsSlash": + return t("Object name cannot contain slashes") + case "sameName": + return t("New object name must be different") + } + }, + [t], + ) + + const renameValidation = renameSourceKey ? validateObjectRename(renameSourceKey, renameName) : "empty" + const columns: ColumnDef[] = React.useMemo( () => [ { @@ -409,6 +445,12 @@ export function ObjectList({ {t("Download")} ) : null} + {canCapability("objects.rename", { bucket, objectKey: row.original.Key, prefix }) ? ( + + ) : null} ) : null} {canCapability("objects.delete", { bucket, objectKey: row.original.Key }) ? ( @@ -421,7 +463,7 @@ export function ObjectList({ ), }, ], - [t, displayKey, bucketPath, onOpenInfo, bucket, downloadFile, canCapability, onPreview], + [t, displayKey, bucketPath, onOpenInfo, bucket, downloadFile, canCapability, onPreview, prefix], ) const { table, selectedRowIds } = useDataTable({ @@ -476,6 +518,50 @@ export function ObjectList({ setDeleteDialogOpen(true) } + const openRenameDialog = (key: string) => { + setRenameSourceKey(key) + setRenameName(getObjectBaseName(key)) + setRenameDialogOpen(true) + } + + const handleRenameOpenChange = (open: boolean) => { + if (renameSubmitting) return + setRenameDialogOpen(open) + if (!open) { + setRenameSourceKey("") + setRenameName("") + } + } + + const handleConfirmRename = async () => { + if (!renameSourceKey) return + + const validation = validateObjectRename(renameSourceKey, renameName) + if (validation) { + message.warning(getRenameValidationMessage(validation)) + return + } + + const targetKey = buildRenamedObjectKey(renameSourceKey, renameName) + const loadingMsg = message.loading(t("Renaming object"), { duration: 0 }) + setRenameSubmitting(true) + + try { + await renameObject(renameSourceKey, targetKey) + message.success(t("Object renamed")) + table.resetRowSelection() + setRenameDialogOpen(false) + setRenameSourceKey("") + setRenameName("") + void fetchObjects({ resetPagination: true }) + } catch (err) { + message.error((err as Error)?.message ?? t("Rename Failed")) + } finally { + loadingMsg.destroy() + setRenameSubmitting(false) + } + } + const handleConfirmDelete = async () => { const keys = [...deleteDialogKeys] setDeleteDialogOpen(false) @@ -722,6 +808,37 @@ export function ObjectList({ + + + + + {t("Rename Object")} + {renameSourceKey} + +
+ setRenameName(e.target.value)} + aria-label={t("Object Name")} + autoComplete="off" + spellCheck={false} + autoFocus + /> + {renameValidation ? ( +

{getRenameValidationMessage(renameValidation)}

+ ) : null} +
+ + + + +
+
) } diff --git a/hooks/use-object.ts b/hooks/use-object.ts index c7ffa738..009a899e 100644 --- a/hooks/use-object.ts +++ b/hooks/use-object.ts @@ -2,6 +2,7 @@ import { useCallback } from "react" import { + CopyObjectCommand, DeleteObjectCommand, GetObjectLegalHoldCommand, GetObjectRetentionCommand, @@ -18,6 +19,7 @@ import { } from "@aws-sdk/client-s3" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { useS3 } from "@/contexts/s3-context" +import { encodeObjectCopySource } from "@/lib/object-rename" import { decodeS3UrlEncodedObjectList, decodeS3UrlEncodedObjectVersions } from "@/lib/s3-object-encoding" function attachIncludeDeletedHeader(command: ListObjectsV2Command) { @@ -88,6 +90,24 @@ export function useObject(bucket: string) { [client, bucket], ) + const renameObject = useCallback( + async (sourceKey: string, targetKey: string) => { + await client.send( + new CopyObjectCommand({ + Bucket: bucket, + Key: targetKey, + CopySource: encodeObjectCopySource(bucket, sourceKey), + IfNoneMatch: "*", + MetadataDirective: "COPY", + TaggingDirective: "COPY", + }), + ) + + return deleteObject(sourceKey) + }, + [client, bucket, deleteObject], + ) + const listObject = useCallback( async ( bucketName: string, @@ -278,6 +298,7 @@ export function useObject(bucket: string) { headObject, putObject, deleteObject, + renameObject, getSignedUrl: getSignedUrlFn, listObject, mapAllFiles, diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 7d2b0313..6fe29745 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "يجب أن يكون اسم الكائن الجديد مختلفًا", + "Object name cannot contain slashes": "لا يمكن أن يحتوي اسم الكائن على شرطات مائلة", + "Object name is required": "اسم الكائن مطلوب", + "Object renamed": "تمت إعادة تسمية الكائن", + "Rename": "إعادة التسمية", + "Rename Failed": "فشلت إعادة التسمية", + "Rename Object": "إعادة تسمية الكائن", + "Renaming object": "جارٍ إعادة تسمية الكائن" } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index e8b4df39..016662bd 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "Der neue Objektname muss anders sein", + "Object name cannot contain slashes": "Der Objektname darf keine Schrägstriche enthalten", + "Object name is required": "Der Objektname ist erforderlich", + "Object renamed": "Objekt wurde umbenannt", + "Rename": "Umbenennen", + "Rename Failed": "Umbenennen fehlgeschlagen", + "Rename Object": "Objekt umbenennen", + "Renaming object": "Objekt wird umbenannt" } diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index d590d37a..e2862925 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "New object name must be different", + "Object name cannot contain slashes": "Object name cannot contain slashes", + "Object name is required": "Object name is required", + "Object renamed": "Object renamed", + "Rename": "Rename", + "Rename Failed": "Rename Failed", + "Rename Object": "Rename Object", + "Renaming object": "Renaming object" } diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index e843e692..3f1a75eb 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "El nuevo nombre del objeto debe ser diferente", + "Object name cannot contain slashes": "El nombre del objeto no puede contener barras", + "Object name is required": "El nombre del objeto es obligatorio", + "Object renamed": "Objeto renombrado", + "Rename": "Renombrar", + "Rename Failed": "Error al renombrar", + "Rename Object": "Renombrar objeto", + "Renaming object": "Renombrando objeto" } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index a3acaeda..fb33c28d 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "Le nouveau nom de l’objet doit être différent", + "Object name cannot contain slashes": "Le nom de l’objet ne peut pas contenir de barres obliques", + "Object name is required": "Le nom de l’objet est obligatoire", + "Object renamed": "Objet renommé", + "Rename": "Renommer", + "Rename Failed": "Échec du renommage", + "Rename Object": "Renommer l’objet", + "Renaming object": "Renommage de l’objet" } diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 28e2c79a..902b7a7e 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "Nama objek baru harus berbeda", + "Object name cannot contain slashes": "Nama objek tidak boleh berisi garis miring", + "Object name is required": "Nama objek wajib diisi", + "Object renamed": "Objek diganti namanya", + "Rename": "Ganti nama", + "Rename Failed": "Gagal mengganti nama", + "Rename Object": "Ganti nama objek", + "Renaming object": "Mengganti nama objek" } diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 7d832400..d851c378 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "Il nuovo nome dell’oggetto deve essere diverso", + "Object name cannot contain slashes": "Il nome dell’oggetto non può contenere barre", + "Object name is required": "Il nome dell’oggetto è obbligatorio", + "Object renamed": "Oggetto rinominato", + "Rename": "Rinomina", + "Rename Failed": "Rinomina non riuscita", + "Rename Object": "Rinomina oggetto", + "Renaming object": "Rinomina oggetto in corso" } diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 18bd7fd7..e4cc8058 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "新しいオブジェクト名は異なる必要があります", + "Object name cannot contain slashes": "オブジェクト名にスラッシュは使用できません", + "Object name is required": "オブジェクト名は必須です", + "Object renamed": "オブジェクト名を変更しました", + "Rename": "名前を変更", + "Rename Failed": "名前の変更に失敗しました", + "Rename Object": "オブジェクト名を変更", + "Renaming object": "オブジェクト名を変更中" } diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index b3e1856b..71b01408 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "새 객체 이름은 달라야 합니다", + "Object name cannot contain slashes": "객체 이름에는 슬래시를 포함할 수 없습니다", + "Object name is required": "객체 이름은 필수입니다", + "Object renamed": "객체 이름이 변경되었습니다", + "Rename": "이름 변경", + "Rename Failed": "이름 변경 실패", + "Rename Object": "객체 이름 변경", + "Renaming object": "객체 이름 변경 중" } diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index 59493a48..d1e84f6a 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "O novo nome do objeto deve ser diferente", + "Object name cannot contain slashes": "O nome do objeto não pode conter barras", + "Object name is required": "O nome do objeto é obrigatório", + "Object renamed": "Objeto renomeado", + "Rename": "Renomear", + "Rename Failed": "Falha ao renomear", + "Rename Object": "Renomear objeto", + "Renaming object": "Renomeando objeto" } diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index c74c93d5..c102c674 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "Новое имя объекта должно отличаться", + "Object name cannot contain slashes": "Имя объекта не может содержать косые черты", + "Object name is required": "Требуется имя объекта", + "Object renamed": "Объект переименован", + "Rename": "Переименовать", + "Rename Failed": "Не удалось переименовать", + "Rename Object": "Переименовать объект", + "Renaming object": "Переименование объекта" } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index a76e4edc..268f7d38 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "Yeni nesne adı farklı olmalıdır", + "Object name cannot contain slashes": "Nesne adı eğik çizgi içeremez", + "Object name is required": "Nesne adı gerekli", + "Object renamed": "Nesne yeniden adlandırıldı", + "Rename": "Yeniden adlandır", + "Rename Failed": "Yeniden adlandırma başarısız", + "Rename Object": "Nesneyi yeniden adlandır", + "Renaming object": "Nesne yeniden adlandırılıyor" } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index f37bf483..343ee21a 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -1346,5 +1346,13 @@ "Selection Locked": "Selection Locked", "Decommission cleared": "Decommission cleared", "Failed to clear decommission": "Failed to clear decommission", - "Clear Decommission": "Clear Decommission" + "Clear Decommission": "Clear Decommission", + "New object name must be different": "Tên đối tượng mới phải khác", + "Object name cannot contain slashes": "Tên đối tượng không được chứa dấu gạch chéo", + "Object name is required": "Tên đối tượng là bắt buộc", + "Object renamed": "Đã đổi tên đối tượng", + "Rename": "Đổi tên", + "Rename Failed": "Đổi tên thất bại", + "Rename Object": "Đổi tên đối tượng", + "Renaming object": "Đang đổi tên đối tượng" } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index a753788a..97dac869 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -1346,5 +1346,13 @@ "Selection Locked": "选择已锁定", "Decommission cleared": "退役记录已清理", "Failed to clear decommission": "清理退役记录失败", - "Clear Decommission": "清理退役" + "Clear Decommission": "清理退役", + "New object name must be different": "新对象名称必须不同", + "Object name cannot contain slashes": "对象名称不能包含斜杠", + "Object name is required": "对象名称不能为空", + "Object renamed": "对象已重命名", + "Rename": "重命名", + "Rename Failed": "重命名失败", + "Rename Object": "重命名对象", + "Renaming object": "正在重命名对象" } diff --git a/lib/object-rename.ts b/lib/object-rename.ts new file mode 100644 index 00000000..7c7eed2e --- /dev/null +++ b/lib/object-rename.ts @@ -0,0 +1,41 @@ +export type ObjectRenameValidation = "empty" | "containsSlash" | "sameName" + +export function getObjectBaseName(key: string): string { + const normalizedKey = key.replace(/\/+$/, "") + const separatorIndex = normalizedKey.lastIndexOf("/") + return separatorIndex >= 0 ? normalizedKey.slice(separatorIndex + 1) : normalizedKey +} + +export function normalizeObjectRenameName(value: string): string { + return value.trim() +} + +export function buildRenamedObjectKey(sourceKey: string, newName: string): string { + const normalizedName = normalizeObjectRenameName(newName) + const normalizedSourceKey = sourceKey.replace(/\/+$/, "") + const separatorIndex = normalizedSourceKey.lastIndexOf("/") + return separatorIndex >= 0 ? `${normalizedSourceKey.slice(0, separatorIndex + 1)}${normalizedName}` : normalizedName +} + +export function validateObjectRename(sourceKey: string, newName: string): ObjectRenameValidation | null { + const normalizedName = normalizeObjectRenameName(newName) + + if (!normalizedName) { + return "empty" + } + + if (normalizedName.includes("/")) { + return "containsSlash" + } + + if (buildRenamedObjectKey(sourceKey, normalizedName) === sourceKey) { + return "sameName" + } + + return null +} + +export function encodeObjectCopySource(bucket: string, key: string): string { + const encodedKey = key.split("/").map(encodeURIComponent).join("/") + return `/${encodeURIComponent(bucket)}/${encodedKey}` +} diff --git a/lib/permission-capabilities.ts b/lib/permission-capabilities.ts index 7af3cff6..b4673967 100644 --- a/lib/permission-capabilities.ts +++ b/lib/permission-capabilities.ts @@ -26,6 +26,7 @@ export type ConsoleCapability = | "objects.view" | "objects.preview" | "objects.download" + | "objects.rename" | "objects.delete" | "objects.bulkDelete" | "objects.tag.view" @@ -76,6 +77,10 @@ const CAPABILITY_REQUIREMENTS: Record import(new URL("../../lib/object-rename.ts", import.meta.url).href) + +test("getObjectBaseName returns the final object name segment", async () => { + const { getObjectBaseName } = await loadObjectRename() + + assert.equal(getObjectBaseName("photos/2026/image.jpg"), "image.jpg") + assert.equal(getObjectBaseName("image.jpg"), "image.jpg") +}) + +test("buildRenamedObjectKey keeps renamed objects in the same prefix", async () => { + const { buildRenamedObjectKey } = await loadObjectRename() + + assert.equal(buildRenamedObjectKey("photos/2026/image.jpg", "renamed.jpg"), "photos/2026/renamed.jpg") + assert.equal(buildRenamedObjectKey("image.jpg", "renamed.jpg"), "renamed.jpg") +}) + +test("validateObjectRename rejects empty, nested, and unchanged names", async () => { + const { validateObjectRename } = await loadObjectRename() + + assert.equal(validateObjectRename("photos/image.jpg", ""), "empty") + assert.equal(validateObjectRename("photos/image.jpg", "nested/image.jpg"), "containsSlash") + assert.equal(validateObjectRename("photos/image.jpg", "image.jpg"), "sameName") + assert.equal(validateObjectRename("photos/image.jpg", "renamed.jpg"), null) +}) + +test("encodeObjectCopySource URL-encodes bucket and key path segments", async () => { + const { encodeObjectCopySource } = await loadObjectRename() + + assert.equal(encodeObjectCopySource("my.bucket", "a b/c+中文.txt"), "/my.bucket/a%20b/c%2B%E4%B8%AD%E6%96%87.txt") +})