diff --git a/apps/desktop/src/components/redis/RedisValueViewer.hashCopy.spec.ts b/apps/desktop/src/components/redis/RedisValueViewer.hashCopy.spec.ts new file mode 100644 index 0000000000..a46de40350 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisValueViewer.hashCopy.spec.ts @@ -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(); + 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(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"); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisValueViewer.vue b/apps/desktop/src/components/redis/RedisValueViewer.vue index abfd71b954..4002d0fb10 100644 --- a/apps/desktop/src/components/redis/RedisValueViewer.vue +++ b/apps/desktop/src/components/redis/RedisValueViewer.vue @@ -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) })" > - +
+ + + + + + + {{ t("redis.copyField") }} + {{ t("grid.copyValue") }} + {{ t("redis.copyFieldValue") }} + + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 258dedc2ea..83f9b2b8e4 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 23b421d0c8..c52d4eb7e1 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 44dbc61f94..ec1a899749 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 2d773bab03..4180c7f47a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4826,6 +4826,9 @@ export default withEnglishFallback({ memberDetail: "メンバー詳細", viewMember: "完全な値を表示", copyMember: "メンバーをコピー", + copyField: "フィールドをコピー", + copyFieldValue: "フィールドと値をコピー", + copyOptions: "コピーオプション", copyKeyName: "キー名をコピー", copyKeyPath: "キーパスをコピー", deleteGroupKeys: "キーを一括削除", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 758e9fbcb2..7062ecbcbe 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4423,6 +4423,9 @@ export default withEnglishFallback({ memberDetail: "멤버 상세", viewMember: "전체 값 보기", copyMember: "멤버 복사", + copyField: "필드 복사", + copyFieldValue: "필드 및 값 복사", + copyOptions: "복사 옵션", copyKeyName: "키 이름 복사", copyKeyPath: "키 경로 복사", deleteGroupKeys: "키 일괄 삭제", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 86f288e50b..ac183a7cb2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index f567c411c7..5c8104ad4b 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 40143c0af9..d9dcae25d0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5070,6 +5070,9 @@ export default withEnglishFallback({ memberDetail: "成员详情", viewMember: "查看完整内容", copyMember: "复制成员", + copyField: "复制字段", + copyFieldValue: "复制字段和值", + copyOptions: "复制选项", copyKeyName: "复制键名", copyKeyPath: "复制路径", deleteGroupKeys: "批量删除键", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6088cd2e93..03cfbd8cc9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4120,6 +4120,9 @@ export default withEnglishFallback({ memberDetail: "成員詳情", viewMember: "檢視完整內容", copyMember: "複製成員", + copyField: "複製欄位", + copyFieldValue: "複製欄位和值", + copyOptions: "複製選項", copyKeyName: "複製鍵名", copyKeyPath: "複製路徑", deleteGroupKeys: "批量刪除 key", diff --git a/apps/desktop/src/lib/redis/redisValuePresentation.ts b/apps/desktop/src/lib/redis/redisValuePresentation.ts index f9cc10e6fb..247db95634 100644 --- a/apps/desktop/src/lib/redis/redisValuePresentation.ts +++ b/apps/desktop/src/lib/redis/redisValuePresentation.ts @@ -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) { diff --git a/packages/app-tests/redisValuePresentation.test.ts b/packages/app-tests/redisValuePresentation.test.ts index 5c836e0758..a7845a0ecd 100644 --- a/packages/app-tests/redisValuePresentation.test.ts +++ b/packages/app-tests/redisValuePresentation.test.ts @@ -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 }), '\n\n 1\n'); }); + +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"); +}); +