feat: add object rename action (#146)

This commit is contained in:
Zhengchao An
2026-07-04 13:38:06 +08:00
committed by GitHub
parent a9bccd954a
commit c7af406b4d
19 changed files with 345 additions and 16 deletions
+119 -2
View File
@@ -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<string[]>([])
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<ObjectRow>[] = React.useMemo(
() => [
{
@@ -409,6 +445,12 @@ export function ObjectList({
<span>{t("Download")}</span>
</Button>
) : null}
{canCapability("objects.rename", { bucket, objectKey: row.original.Key, prefix }) ? (
<Button variant="outline" size="sm" onClick={() => openRenameDialog(row.original.Key)}>
<RiEdit2Line className="size-4" aria-hidden />
<span>{t("Rename")}</span>
</Button>
) : 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<ObjectRow>({
@@ -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({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={renameDialogOpen} onOpenChange={handleRenameOpenChange} disablePointerDismissal>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Rename Object")}</DialogTitle>
<DialogDescription className="break-all">{renameSourceKey}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Input
name="object-rename-name"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
aria-label={t("Object Name")}
autoComplete="off"
spellCheck={false}
autoFocus
/>
{renameValidation ? (
<p className="text-xs text-destructive">{getRenameValidationMessage(renameValidation)}</p>
) : null}
</div>
<DialogFooter>
<Button variant="outline" disabled={renameSubmitting} onClick={() => handleRenameOpenChange(false)}>
{t("Cancel")}
</Button>
<Button disabled={Boolean(renameValidation) || renameSubmitting} onClick={handleConfirmRename}>
{t("Rename")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+21
View File
@@ -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,
+9 -1
View File
@@ -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": "جارٍ إعادة تسمية الكائن"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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 lobjet doit être différent",
"Object name cannot contain slashes": "Le nom de lobjet ne peut pas contenir de barres obliques",
"Object name is required": "Le nom de lobjet est obligatoire",
"Object renamed": "Objet renommé",
"Rename": "Renommer",
"Rename Failed": "Échec du renommage",
"Rename Object": "Renommer lobjet",
"Renaming object": "Renommage de lobjet"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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 delloggetto deve essere diverso",
"Object name cannot contain slashes": "Il nome delloggetto non può contenere barre",
"Object name is required": "Il nome delloggetto è obbligatorio",
"Object renamed": "Oggetto rinominato",
"Rename": "Rinomina",
"Rename Failed": "Rinomina non riuscita",
"Rename Object": "Rinomina oggetto",
"Renaming object": "Rinomina oggetto in corso"
}
+9 -1
View File
@@ -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": "オブジェクト名を変更中"
}
+9 -1
View File
@@ -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": "객체 이름 변경 중"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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": "Переименование объекта"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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": "正在重命名对象"
}
+41
View File
@@ -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}`
}
+5
View File
@@ -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<ConsoleCapability, CapabilityRequirement[]
"objects.view": [{ actions: ["s3:GetObject"], resource: "object" }],
"objects.preview": [{ actions: ["s3:GetObject"], resource: "object" }],
"objects.download": [{ actions: ["s3:GetObject"], resource: "object" }],
"objects.rename": [
{ actions: ["s3:GetObject", "s3:DeleteObject"], resource: "object" },
{ actions: ["s3:PutObject"], resource: "objectPattern" },
],
"objects.delete": [{ actions: ["s3:DeleteObject"], resource: "object" }],
"objects.bulkDelete": [{ actions: ["s3:DeleteObject"], resource: "objectPattern" }],
"objects.tag.view": [{ actions: ["s3:GetObjectTagging"], resource: "object" }],
+33
View File
@@ -0,0 +1,33 @@
import test from "node:test"
import assert from "node:assert/strict"
const loadObjectRename = () => 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")
})