feat(redis): add hash field and value copy targets

This commit is contained in:
Abeautifulsnow
2026-09-08 22:34:36 +08:00
committed by GitHub
parent 798733fd29
commit 4528a15492
13 changed files with 293 additions and 1 deletions
@@ -0,0 +1,204 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
redisGetValue: vi.fn(),
redisGetTtl: vi.fn(),
redisSetTtl: vi.fn(),
redisSetExpireAt: vi.fn(),
redisLoadMore: vi.fn(),
copyToClipboard: vi.fn().mockResolvedValue(undefined),
toast: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
redisGetValue: mocks.redisGetValue,
redisGetTtl: mocks.redisGetTtl,
redisSetTtl: mocks.redisSetTtl,
redisSetExpireAt: mocks.redisSetExpireAt,
redisLoadMore: mocks.redisLoadMore,
}));
vi.mock("@/lib/common/clipboard", () => ({ copyToClipboard: mocks.copyToClipboard }));
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
useEditorFontFamilyStyle: () => ({}),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/lib/common/shikiJsonHighlighter", () => ({
createShikiJsonHighlighter: vi.fn().mockResolvedValue(() => ""),
}));
// The row menu only needs a trigger that forwards attrs and an item that emits
// `select`; real reka-ui behaviour is out of scope for this test.
vi.mock("@/components/ui/dropdown-menu", async () => {
const { defineComponent, h } = await import("vue");
const slotHost = defineComponent({
setup:
(_props, { slots }) =>
() =>
h("div", slots.default?.()),
});
return {
DropdownMenu: slotHost,
DropdownMenuContent: slotHost,
DropdownMenuLabel: slotHost,
DropdownMenuSeparator: slotHost,
DropdownMenuTrigger: defineComponent({
inheritAttrs: false,
setup:
(_props, { attrs, slots }) =>
() =>
h("div", attrs, slots.default?.()),
}),
DropdownMenuItem: defineComponent({
inheritAttrs: false,
emits: ["select"],
setup(_props, { attrs, slots, emit }) {
return () => h("button", { ...attrs, onClick: () => emit("select") }, slots.default?.());
},
}),
};
});
import RedisValueViewer from "./RedisValueViewer.vue";
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
function createLocalStorage(): Storage {
const entries = new Map<string, string>();
return {
get length() {
return entries.size;
},
clear() {
entries.clear();
},
getItem(key) {
return entries.get(key) ?? null;
},
key(index) {
return [...entries.keys()][index] ?? null;
},
removeItem(key) {
entries.delete(key);
},
setItem(key, value) {
entries.set(key, String(value));
},
};
}
afterEach(() => {
for (const { unmount, host } of mountedApps.splice(0)) {
unmount();
host.remove();
}
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("localStorage", createLocalStorage());
});
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
await Promise.resolve();
await nextTick();
}
function blob(text: string) {
return { raw_base64: btoa(text), encoding: "utf8" as const };
}
function hashValue() {
return {
key_display: "log:processed:2026-09-08:24",
key_raw: "log:processed:2026-09-08:24",
ttl: 200000,
redis_type: "hash",
data: {
kind: "hash" as const,
items: [{ field: blob("1653624095260016668"), value: blob("S") }],
total: 1,
scan_cursor: undefined,
},
};
}
const testI18nMessages = {
en: {
grid: { copyValue: "Copy value" },
redis: {
copyField: "Copy field",
copyFieldValue: "Copy field + value",
copyOptions: "Copy options",
copied: "Copied",
},
},
};
function mountViewer() {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(
defineComponent({
setup() {
return () => h(RedisValueViewer, { connectionId: "connection", db: 0, keyDisplay: "key", keyRaw: "key" });
},
}),
);
app.use(createI18n({ legacy: false, locale: "en", messages: testI18nMessages, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
}
function clickCopy(selector: string) {
const button = document.querySelector<HTMLButtonElement>(selector);
expect(button).not.toBeNull();
button!.click();
}
describe("Redis hash row copy menu", () => {
it("copies the value from the main button without opening the member detail", async () => {
mocks.redisGetValue.mockResolvedValue(hashValue());
mountViewer();
await settle();
clickCopy("[data-redis-copy-value]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenCalledWith("S");
expect(document.querySelector("[data-redis-member-utf8-viewer]")).toBeNull();
});
it("offers field, value and field + value targets in the dropdown", async () => {
mocks.redisGetValue.mockResolvedValue(hashValue());
mountViewer();
await settle();
expect(document.querySelector("[data-redis-copy-menu]")).not.toBeNull();
clickCopy("[data-redis-copy-item-field]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("1653624095260016668");
clickCopy("[data-redis-copy-item-value]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("S");
clickCopy("[data-redis-copy-item-field-value]");
await settle();
expect(mocks.copyToClipboard).toHaveBeenLastCalledWith("1653624095260016668\tS");
});
});
@@ -41,6 +41,7 @@ import {
redisJsonValueText,
normalizeRedisJsonDraft,
redisClipboardSafeText,
redisHashRowCopyText,
redisMemberCopyText,
redisValueCopyText,
redisValueCollectionItems,
@@ -54,6 +55,7 @@ import {
jsonToYamlText,
REDIS_VALUE_CODEC_ORDER,
type RedisCollectionItem,
type RedisHashRowCopyTarget,
type RedisValueCodec,
type RedisValueFormat,
} from "@/lib/redis/redisValuePresentation";
@@ -1863,6 +1865,10 @@ function copyMember(value: unknown) {
void copyText(redisMemberCopyText(value));
}
function copyHashRow(item: RedisHashItem, target: RedisHashRowCopyTarget) {
void copyText(redisHashRowCopyText(item.field, item.value, target));
}
function selectMember(title: string, value: unknown, context: RedisMemberContext, identity?: string) {
const detail = formatRedisMemberDetail(value, { allowJsonText: true });
selectedMemberTitle.value = title;
@@ -3124,7 +3130,19 @@ defineExpose({ focusSearch });
@click.stop="viewMember(formatValue(row.value.field), row.value.value, { kind: 'hash', field: redisBlobText(row.value.field), canEdit: redisBlobText(row.value.field) != null && canEditRedisMemberDetail('hash', row.value.value) })"
><Eye class="w-3 h-3"
/></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100" :title="t('redis.copyMember')" @click.stop="copyMember(row.value.value)"><Copy class="w-3 h-3" /></Button>
<div class="flex h-5 shrink-0 overflow-hidden rounded opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100" @click.stop>
<Button data-redis-copy-value variant="ghost" size="icon" class="h-5 w-[18px] rounded-none px-0" :title="t('grid.copyValue')" :aria-label="t('grid.copyValue')" @click="copyHashRow(row.value, 'value')"><Copy class="w-3 h-3" /></Button>
<DropdownMenu :key="row.id">
<DropdownMenuTrigger as-child>
<Button data-redis-copy-menu variant="ghost" size="icon" class="h-5 w-4 rounded-none border-l px-0" :title="t('redis.copyOptions')" :aria-label="t('redis.copyOptions')"><ChevronDown class="h-2.5 w-2.5" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuItem data-redis-copy-item-field @select="copyHashRow(row.value, 'field')">{{ t("redis.copyField") }}</DropdownMenuItem>
<DropdownMenuItem data-redis-copy-item-value @select="copyHashRow(row.value, 'value')">{{ t("grid.copyValue") }}</DropdownMenuItem>
<DropdownMenuItem data-redis-copy-item-field-value @select="copyHashRow(row.value, 'fieldValue')">{{ t("redis.copyFieldValue") }}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive" :disabled="!canDeleteHashItem(row.value)" @click.stop="requestHashDel(redisBlobText(row.value.field))"><Trash2 class="w-3 h-3" /></Button>
</div>
</div>
+3
View File
@@ -5086,6 +5086,9 @@ export default {
memberDetail: "Member detail",
viewMember: "View full value",
copyMember: "Copy member",
copyField: "Copy field",
copyFieldValue: "Copy field + value",
copyOptions: "Copy options",
copyKeyName: "Copy key name",
copyKeyPath: "Copy key path",
deleteGroupKeys: "Delete loaded keys",
+3
View File
@@ -4799,6 +4799,9 @@ export default withEnglishFallback({
memberDetail: "Detalle del miembro",
viewMember: "Ver valor completo",
copyMember: "Copiar miembro",
copyField: "Copiar campo",
copyFieldValue: "Copiar campo + valor",
copyOptions: "Opciones de copia",
copyKeyName: "Copiar nombre de clave",
copyKeyPath: "Copiar ruta de clave",
deleteGroupKeys: "Eliminar claves cargadas",
+3
View File
@@ -4797,6 +4797,9 @@ export default withEnglishFallback({
memberDetail: "Dettaglio membro",
viewMember: "Visualizza valore completo",
copyMember: "Copia membro",
copyField: "Copia campo",
copyFieldValue: "Copia campo + valore",
copyOptions: "Opzioni di copia",
copyKeyName: "Copia nome chiave",
copyKeyPath: "Copia percorso chiave",
deleteGroupKeys: "Elimina chiavi caricate",
+3
View File
@@ -4826,6 +4826,9 @@ export default withEnglishFallback({
memberDetail: "メンバー詳細",
viewMember: "完全な値を表示",
copyMember: "メンバーをコピー",
copyField: "フィールドをコピー",
copyFieldValue: "フィールドと値をコピー",
copyOptions: "コピーオプション",
copyKeyName: "キー名をコピー",
copyKeyPath: "キーパスをコピー",
deleteGroupKeys: "キーを一括削除",
+3
View File
@@ -4423,6 +4423,9 @@ export default withEnglishFallback({
memberDetail: "멤버 상세",
viewMember: "전체 값 보기",
copyMember: "멤버 복사",
copyField: "필드 복사",
copyFieldValue: "필드 및 값 복사",
copyOptions: "복사 옵션",
copyKeyName: "키 이름 복사",
copyKeyPath: "키 경로 복사",
deleteGroupKeys: "키 일괄 삭제",
+3
View File
@@ -4799,6 +4799,9 @@ export default withEnglishFallback({
memberDetail: "Detalhe do membro",
viewMember: "Ver valor completo",
copyMember: "Copiar membro",
copyField: "Copiar campo",
copyFieldValue: "Copiar campo + valor",
copyOptions: "Opções de cópia",
copyKeyName: "Copiar nome da chave",
copyKeyPath: "Copiar caminho da chave",
deleteGroupKeys: "Excluir chaves carregadas",
+3
View File
@@ -4984,6 +4984,9 @@ export default withEnglishFallback({
memberDetail: "Üye ayrıntısı",
viewMember: "Tam değeri görüntüle",
copyMember: "Üyeyi kopyala",
copyField: "Alanı kopyala",
copyFieldValue: "Alan + değer kopyala",
copyOptions: "Kopyalama seçenekleri",
copyKeyName: "Anahtar adını kopyala",
copyKeyPath: "Anahtar yolunu kopyala",
deleteGroupKeys: "Yüklenen anahtarları sil",
+3
View File
@@ -5070,6 +5070,9 @@ export default withEnglishFallback({
memberDetail: "成员详情",
viewMember: "查看完整内容",
copyMember: "复制成员",
copyField: "复制字段",
copyFieldValue: "复制字段和值",
copyOptions: "复制选项",
copyKeyName: "复制键名",
copyKeyPath: "复制路径",
deleteGroupKeys: "批量删除键",
+3
View File
@@ -4120,6 +4120,9 @@ export default withEnglishFallback({
memberDetail: "成員詳情",
viewMember: "檢視完整內容",
copyMember: "複製成員",
copyField: "複製欄位",
copyFieldValue: "複製欄位和值",
copyOptions: "複製選項",
copyKeyName: "複製鍵名",
copyKeyPath: "複製路徑",
deleteGroupKeys: "批量刪除 key",
@@ -381,6 +381,19 @@ export function redisMemberCopyText(value: unknown): string {
return redisClipboardSafeText(text);
}
/** Copy targets for one hash row: its field, its value, or both. */
export type RedisHashRowCopyTarget = "field" | "value" | "fieldValue";
/**
* Copy text for one hash row. `fieldValue` joins the two with a tab so the
* result pastes into a spreadsheet as two columns, matching the grid's TSV copy.
*/
export function redisHashRowCopyText(field: unknown, value: unknown, target: RedisHashRowCopyTarget): string {
if (target === "value") return redisMemberCopyText(value);
const fieldText = redisMemberCopyText(field);
return target === "field" ? fieldText : `${fieldText}\t${redisMemberCopyText(value)}`;
}
export function redisClipboardSafeText(value: string): string {
let output = "";
for (const ch of value) {
+30
View File
@@ -16,6 +16,7 @@ import {
parseRedisJsonDetail,
preferredRedisValueFormat,
redisClipboardSafeText,
redisHashRowCopyText,
redisMemberCopyText,
redisValueCopyText,
} from "../../apps/desktop/src/lib/redis/redisValuePresentation.ts";
@@ -318,3 +319,32 @@ test("renders JSON values as YAML and XML text", () => {
assert.equal(jsonToYamlText({ id: 1, tags: ["a", "b"] }), "id: 1\ntags:\n - a\n - b\n");
assert.equal(jsonToXmlText({ id: 1 }), '<?xml version="1.0" encoding="UTF-8"?>\n<root>\n <id>1</id>\n</root>');
});
test("copies hash row field, value, and field+value targets", () => {
const field = blobFromText("1653624095260016668");
const value = blobFromText("S");
assert.equal(redisHashRowCopyText(field, value, "field"), "1653624095260016668");
assert.equal(redisHashRowCopyText(field, value, "value"), "S");
assert.equal(redisHashRowCopyText(field, value, "fieldValue"), "1653624095260016668\tS");
});
test("escapes clipboard-unsafe controls in hash row copies", () => {
const field = blobFromText("name\x00suffix");
const value = blobFromText("Ada");
assert.equal(redisHashRowCopyText(field, value, "field"), "name\\x00suffix");
assert.equal(redisHashRowCopyText(field, value, "fieldValue"), "name\\x00suffix\tAda");
});
test("copies plain string hash rows without JSON reformatting", () => {
assert.equal(redisHashRowCopyText('{"id":1}', "1", "field"), '{"id":1}');
assert.equal(redisHashRowCopyText('{"id":1}', "1", "fieldValue"), '{"id":1}\t1');
});
test("copies the value target without resolving the field", () => {
// The default row copy must stay a pure value read: a hash row always has a
// field, but the value path must not depend on the field being copyable.
assert.equal(redisHashRowCopyText(undefined, blobFromText("S"), "value"), "S");
});