feat(redis): support renaming keys

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-18 15:23:08 +08:00
1 parent 61103523de
commit 168f2a472b
21 files changed
+297 -2

No files matched your search

@@ -875,6 +875,38 @@ function onKeyDeleted(keyRaw: string) {
removeKnownKey(keyRaw);
}
function onKeyRenamed(oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: string) {
connectionStore.invalidateCompletionCache(props.connectionId, String(props.db));
if (isSearchMode.value) {
void loadKeys();
return;
}
const previous = flatKeys.value.find((key) => key.key_raw === oldKeyRaw);
if (!previous) {
void loadKeys();
return;
}
loadedKeyRaws.delete(oldKeyRaw);
loadedKeyRaws.add(newKeyRaw);
flatKeys.value = flatKeys.value.map((key) => (key.key_raw === oldKeyRaw ? { ...key, key_raw: newKeyRaw, key_display: newKeyDisplay } : key));
if (selectedKeyRaw.value === oldKeyRaw) selectedKeyRaw.value = newKeyRaw;
if (checkedKeys.value.has(oldKeyRaw)) {
const nextCheckedKeys = new Set(checkedKeys.value);
nextCheckedKeys.delete(oldKeyRaw);
nextCheckedKeys.add(newKeyRaw);
checkedKeys.value = nextCheckedKeys;
}
if (useFlatKeySearchRows.value) {
treeKeys.value = [];
treeIndex = null;
refreshSelectedGroupLeafCounts();
} else {
rebuildTree(false);
}
}
function redisValueToKeyInfo(value: RedisValue): RedisKeyInfo {
return {
key_display: value.key_display,
@@ -2093,7 +2125,19 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
<TabsContent value="detail" class="m-0 min-h-0 flex-1 flex flex-col">
<RedisValueViewer v-if="selectedKey" ref="valueViewerRef" :key="selectedKey.key_raw" :connection-id="connectionId" :db="db" :key-display="selectedKey.key_display" :key-raw="selectedKey.key_raw" :metadata="selectedKey" @deleted="onKeyDeleted" @loaded="onKeyLoaded" />
<RedisValueViewer
v-if="selectedKey"
ref="valueViewerRef"
:key="selectedKey.key_raw"
:connection-id="connectionId"
:db="db"
:key-display="selectedKey.key_display"
:key-raw="selectedKey.key_raw"
:metadata="selectedKey"
@deleted="onKeyDeleted"
@renamed="onKeyRenamed"
@loaded="onKeyLoaded"
/>
<div v-else class="flex-1 flex items-center justify-center text-xs text-muted-foreground">
{{ t("redis.selectKeyForDetail") }}
</div>
@@ -55,6 +55,7 @@ import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/lib/common/safeJsonFormat";
import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry";
import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession";
const { t, locale } = useI18n();
const { toast } = useToast();
@@ -74,7 +75,7 @@ const redisExpiryTransport = {
setExpireAt: api.redisSetExpireAt,
};
const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue] }>();
const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue]; renamed: [oldKeyRaw: string, newKeyRaw: string, newKeyDisplay: string] }>();
const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap";
const REDIS_JSON_UNICODE_MODE_STORAGE_KEY = "dbx-redis-json-unicode-mode";
@@ -90,6 +91,12 @@ const REDIS_STREAM_MIN_ROW_HEIGHT = 96;
const data = ref<RedisValue | null>(null);
const loading = ref(false);
const loadingMore = ref(false);
const showRenameKeyDialog = ref(false);
const renamingKey = ref(false);
const renameKeyName = ref("");
const renameKeyError = ref("");
const renameKeyInitialName = ref("");
const renameKeyInitialRaw = ref("");
let loadRequestId = 0;
const streamTab = ref<"entries" | "groups">("entries");
const streamEntries = ref<RedisStreamEntry[]>([]);
@@ -1474,6 +1481,50 @@ function requestDeleteKey() {
showDeleteConfirm.value = true;
}
function openRenameKeyDialog() {
if (!data.value || renamingKey.value) return;
// Display text escapes backslashes and binary bytes. Prefer the original
// UTF-8 text when available so confirming an unchanged name is a true no-op.
renameKeyName.value = redisKeyRawToText(props.keyRaw) ?? data.value.key_display;
renameKeyInitialName.value = renameKeyName.value;
renameKeyInitialRaw.value = props.keyRaw;
renameKeyError.value = "";
showRenameKeyDialog.value = true;
}
function handleRenameKeyDialogOpenChange(open: boolean) {
if (renamingKey.value) return;
showRenameKeyDialog.value = open;
if (!open) renameKeyError.value = "";
}
async function renameKey() {
if (renamingKey.value) return;
if (!renameKeyName.value) {
renameKeyError.value = t("redis.renameKeyNameRequired");
return;
}
const newKeyRaw = renameKeyName.value === renameKeyInitialName.value ? renameKeyInitialRaw.value : redisKeyTextToRaw(renameKeyName.value);
if (newKeyRaw === props.keyRaw) {
showRenameKeyDialog.value = false;
return;
}
renamingKey.value = true;
renameKeyError.value = "";
try {
await api.redisRenameKey(props.connectionId, props.db, props.keyRaw, newKeyRaw);
toast(t("redis.renameKeySuccess", { oldName: props.keyDisplay, newName: renameKeyName.value }), 2000);
showRenameKeyDialog.value = false;
emit("renamed", props.keyRaw, newKeyRaw, redisKeyTextToDisplay(renameKeyName.value));
} catch (error) {
renameKeyError.value = error instanceof Error ? error.message : String(error);
} finally {
renamingKey.value = false;
}
}
async function copyValue() {
if (!data.value) return;
// Copy the decompressed text while the Decompressed view is showing it.
@@ -2497,6 +2548,7 @@ defineExpose({ focusSearch });
</div>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyValue"><Copy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.copyInsertStatement')" :aria-label="t('redis.copyInsertStatement')" @click="copyInsertStatement"><ClipboardCopy class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :title="t('redis.renameKey')" :aria-label="t('redis.renameKey')" @click="openRenameKeyDialog"><Pencil class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-destructive" @click="requestDeleteKey"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
@@ -3184,6 +3236,27 @@ defineExpose({ focusSearch });
<DangerConfirmDialog v-model:open="showDeleteConfirm" :message="t('dangerDialog.deleteMessage')" :details="deleteDetails" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDelete" />
<Dialog :open="showRenameKeyDialog" @update:open="handleRenameKeyDialogOpenChange">
<DialogContent class="sm:max-w-md" :show-close-button="!renamingKey" :style="editorFontFamilyStyle">
<DialogHeader>
<DialogTitle>{{ t("redis.renameKey") }}</DialogTitle>
</DialogHeader>
<label class="grid gap-1.5 text-xs font-medium">
<span>{{ t("redis.renameKeyName") }}</span>
<Input v-model="renameKeyName" class="dbx-editor-font-family" :disabled="renamingKey" autofocus spellcheck="false" @keydown.enter.prevent="renameKey" />
</label>
<p v-if="renameKeyError" class="text-xs text-destructive">{{ renameKeyError }}</p>
<DialogFooter>
<Button variant="ghost" :disabled="renamingKey" @click="showRenameKeyDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="renamingKey || !renameKeyName" @click="renameKey">
<Loader2 v-if="renamingKey" class="mr-1 h-3.5 w-3.5 animate-spin" />
<Pencil v-else class="mr-1 h-3.5 w-3.5" />
{{ t("redis.renameKey") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="showHashFieldTtlDialog" @update:open="handleHashFieldTtlOpenChange">
<DialogContent class="w-[calc(100vw-2rem)] sm:max-w-[460px]">
<DialogHeader>
+4
View File
@@ -4409,6 +4409,10 @@ export default {
createValuePlaceholder: "value",
createKeySubmit: "Create",
createKeyNameRequired: "Enter a key name",
renameKey: "Rename Key",
renameKeyName: "Key name",
renameKeyNameRequired: "Enter a key name",
renameKeySuccess: 'Renamed "{oldName}" to "{newName}"',
createFieldRequired: "Enter a hash field",
createScoreInvalid: "Enter a valid score",
jsonModuleNotAvailable: "ReJSON module is not loaded on this Redis server",
+4
View File
@@ -4247,6 +4247,10 @@ export default withEnglishFallback({
jsonModuleNotAvailable: "El módulo ReJSON no está cargado en este servidor Redis",
createKeySubmit: "Crear",
createKeyNameRequired: "Ingresa una clave",
renameKey: "Renombrar clave",
renameKeyName: "Nombre de la clave",
renameKeyNameRequired: "Ingresa un nombre de clave",
renameKeySuccess: 'Clave "{oldName}" renombrada a "{newName}"',
createFieldRequired: "Ingresa un campo hash",
createScoreInvalid: "Ingresa una puntuación válida",
fieldRequired: "Ingresa un nombre de campo",
+4
View File
@@ -4244,6 +4244,10 @@ export default withEnglishFallback({
createValuePlaceholder: "valore",
createKeySubmit: "Crea",
createKeyNameRequired: "Inserisci un nome chiave",
renameKey: "Rinomina chiave",
renameKeyName: "Nome chiave",
renameKeyNameRequired: "Inserisci un nome chiave",
renameKeySuccess: 'Chiave "{oldName}" rinominata in "{newName}"',
createFieldRequired: "Inserisci un campo hash",
createScoreInvalid: "Inserisci un punteggio valido",
jsonModuleNotAvailable: "Il modulo ReJSON non è caricato su questo server Redis",
+4
View File
@@ -4274,6 +4274,10 @@ export default withEnglishFallback({
createValuePlaceholder: "value",
createKeySubmit: "作成",
createKeyNameRequired: "キー名を入力してください",
renameKey: "キー名を変更",
renameKeyName: "キー名",
renameKeyNameRequired: "キー名を入力してください",
renameKeySuccess: "「{oldName}」を「{newName}」に変更しました",
createFieldRequired: "ハッシュフィールドを入力してください",
createScoreInvalid: "有効なスコアを入力してください",
jsonModuleNotAvailable: "このRedisサーバーにReJSONモジュールがロードされていません",
+4
View File
@@ -3893,6 +3893,10 @@ export default withEnglishFallback({
createValuePlaceholder: "값",
createKeySubmit: "만들기",
createKeyNameRequired: "키 이름을 입력하세요",
renameKey: "키 이름 변경",
renameKeyName: "키 이름",
renameKeyNameRequired: "키 이름을 입력하세요",
renameKeySuccess: '"{oldName}"을(를) "{newName}"(으)로 변경했습니다',
createFieldRequired: "해시 필드를 입력하세요",
createScoreInvalid: "올바른 점수를 입력하세요",
jsonModuleNotAvailable: "이 Redis 서버에 ReJSON 모듈이 로드되지 않았습니다",
+4
View File
@@ -4247,6 +4247,10 @@ export default withEnglishFallback({
jsonModuleNotAvailable: "Módulo ReJSON não está carregado neste servidor Redis",
createKeySubmit: "Criar",
createKeyNameRequired: "Insira um nome de chave",
renameKey: "Renomear chave",
renameKeyName: "Nome da chave",
renameKeyNameRequired: "Insira um nome de chave",
renameKeySuccess: 'Chave "{oldName}" renomeada para "{newName}"',
createFieldRequired: "Insira um campo de hash",
createScoreInvalid: "Insira uma pontuação válida",
fieldRequired: "Insira um nome de campo",
+4
View File
@@ -4396,6 +4396,10 @@ export default withEnglishFallback({
createValuePlaceholder: "value",
createKeySubmit: "新增",
createKeyNameRequired: "请输入 key 名称",
renameKey: "重命名 Key",
renameKeyName: "Key 名称",
renameKeyNameRequired: "请输入 Key 名称",
renameKeySuccess: "已将「{oldName}」重命名为「{newName}」",
createFieldRequired: "请输入 hash 字段",
createScoreInvalid: "请输入有效分数",
jsonModuleNotAvailable: "该 Redis 服务器未加载 ReJSON 模块",
+4
View File
@@ -3573,6 +3573,10 @@ export default withEnglishFallback({
createValuePlaceholder: "value",
createKeySubmit: "建立",
createKeyNameRequired: "請輸入 key 名稱",
renameKey: "重新命名 Key",
renameKeyName: "Key 名稱",
renameKeyNameRequired: "請輸入 Key 名稱",
renameKeySuccess: "已將「{oldName}」重新命名為「{newName}」",
createFieldRequired: "請輸入 hash 欄位",
createScoreInvalid: "請輸入有效分數",
jsonModuleNotAvailable: "該 Redis 伺服器未載入 ReJSON 模組",
+1
View File
@@ -443,6 +443,7 @@ export const redisGetStreamConsumers = forward("redisGetStreamConsumers");
export const redisGetStreamPending = forward("redisGetStreamPending");
export const redisSetString = forward("redisSetString");
export const redisDeleteKey = forward("redisDeleteKey");
export const redisRenameKey = forward("redisRenameKey");
export const redisHashSet = forward("redisHashSet");
export const redisHashDel = forward("redisHashDel");
export const redisHashFieldSetTtl = forward("redisHashFieldSetTtl");
+4
View File
@@ -2535,6 +2535,10 @@ export async function redisDeleteKey(connectionId: string, db: number, keyRaw: s
return post("/api/redis/delete-key", { connectionId, db, keyRaw });
}
export async function redisRenameKey(connectionId: string, db: number, keyRaw: string, newKeyRaw: string): Promise<void> {
return post("/api/redis/rename-key", { connectionId, db, keyRaw, newKeyRaw });
}
export async function redisHashSet(connectionId: string, db: number, keyRaw: string, field: string, value: string, ttl?: number): Promise<void> {
return post("/api/redis/hash-set", {
connectionId,
+4
View File
@@ -2342,6 +2342,10 @@ export async function redisDeleteKey(connectionId: string, db: number, keyRaw: s
return invoke("redis_delete_key", { connectionId, db, keyRaw });
}
export async function redisRenameKey(connectionId: string, db: number, keyRaw: string, newKeyRaw: string): Promise<void> {
return invoke("redis_rename_key", { connectionId, db, keyRaw, newKeyRaw });
}
export async function redisHashSet(connectionId: string, db: number, keyRaw: string, field: string, value: string, ttl?: number): Promise<void> {
return invoke("redis_hash_set", {
connectionId,
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { redisKeyRawToText, redisKeyTextToDisplay, redisKeyTextToRaw } from "./redisCommandSession";
describe("Redis key text encoding", () => {
it("round-trips UTF-8 keys without treating backslashes as display escapes", () => {
const key = "cache\\session";
const raw = redisKeyTextToRaw(key);
expect(redisKeyRawToText(raw)).toBe(key);
expect(redisKeyTextToDisplay(key)).toBe("cache\\\\session");
});
it("does not coerce binary key bytes into a text key name", () => {
expect(redisKeyRawToText("/w==")).toBeNull();
});
});
@@ -20,3 +20,17 @@ export function redisKeyTextToRaw(key: string): string {
}
return btoa(binary);
}
/** Returns null when a Redis key contains bytes that cannot be represented in a text input. */
export function redisKeyRawToText(keyRaw: string): string | null {
try {
const bytes = Uint8Array.from(atob(keyRaw), (character) => character.charCodeAt(0));
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
}
export function redisKeyTextToDisplay(key: string): string {
return key.replaceAll("\\", "\\\\");
}