From 4dab78ac30071977bc2b8c1dd68fe10f819e9d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Fri, 4 Sep 2026 00:28:50 +0800 Subject: [PATCH] feat(redis): add key directory context menu --- .../redis/RedisKeyBrowser.expansion.spec.ts | 68 ++++++++++++++++++- .../src/components/redis/RedisKeyBrowser.vue | 40 +++++++++-- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + 10 files changed, 116 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts index 398488d8b2..0b31ea8a20 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expansion.spec.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ updateRedisDbKeyStats: vi.fn(), listRedisCompletionCommandDocs: vi.fn(), listRedisCompletionKeys: vi.fn(), + copyToClipboard: vi.fn(), redisScanPageSize: 100, infiniteScroll: false, queryResultMaxRowsEnabled: true, @@ -81,6 +82,10 @@ vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }), })); +vi.mock("@/lib/common/clipboard", () => ({ + copyToClipboard: mocks.copyToClipboard, +})); + vi.mock("@/components/ui/button", async () => { const { defineComponent, h } = await import("vue"); return { @@ -202,8 +207,26 @@ vi.mock("@/components/ui/CustomContextMenu.vue", async () => { const { defineComponent, h } = await import("vue"); return { default: defineComponent({ - setup(_, { slots }) { - return () => h("div", slots.default?.({ onContextMenu: () => undefined })); + props: { items: { type: [Array, Function], required: true } }, + setup(props, { slots }) { + return () => { + const items = typeof props.items === "function" ? props.items() : props.items; + return h("div", [ + slots.default?.({ onContextMenu: () => undefined }), + ...items.map((item: any) => + h( + "button", + { + type: "button", + "data-context-menu-item": item.label, + disabled: typeof item.disabled === "function" ? item.disabled() : item.disabled, + onClick: item.action, + }, + item.label, + ), + ), + ]); + }; }, }), }; @@ -307,6 +330,7 @@ function expandGroup(host: HTMLElement, segments: string[]) { expect(checkbox, `group row for ${segments.join(":")}`).toBeDefined(); const row = checkbox!.closest("div.cursor-pointer"); expect(row?.className).toContain("cursor-pointer"); + expect(row?.className).toContain("select-none"); row!.click(); } @@ -504,3 +528,43 @@ describe("RedisKeyBrowser expansion persistence across refresh (issue #7173)", ( expect(mocks.toast).not.toHaveBeenCalled(); }); }); + +describe("RedisKeyBrowser directory context menu (issue #8068)", () => { + it("prefills a new key and copies the directory path", async () => { + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [keyInfo("folder:child")], total_keys: 1 }); + + const host = mountBrowser(); + await settle(); + + const create = host.querySelector('button[data-context-menu-item="redis.createKey"]'); + const copyPath = host.querySelector('button[data-context-menu-item="redis.copyKeyPath"]'); + const deleteKeys = host.querySelector('button[data-context-menu-item="redis.deleteGroupKeys"]'); + expect(create).not.toBeNull(); + expect(copyPath).not.toBeNull(); + expect(deleteKeys?.disabled).toBe(false); + + create!.click(); + await settle(); + expect(host.querySelector('input[placeholder="redis.createKeyNamePlaceholder"]')?.value).toBe("folder:"); + + copyPath!.click(); + await settle(); + expect(mocks.copyToClipboard).toHaveBeenCalledWith("folder"); + }); + + it("hides directory deletion for fuzzy hierarchy results", async () => { + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [keyInfo("folder:child")], total_keys: 1 }); + + const host = mountBrowser(); + await settle(); + + const input = host.querySelector("[data-redis-search-input]")!; + input.value = "folder"; + input.dispatchEvent(new Event("input")); + await nextTick(); + host.querySelector('button[title="redis.fuzzyMatchTitle"]')!.click(); + await settle(); + + expect(host.querySelector('button[data-context-menu-item="redis.deleteGroupKeys"]')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 10ea02651e..97d518a519 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -1107,8 +1107,8 @@ function requestBatchDelete() { showDangerConfirm.value = true; } -function requestGroupDelete(node: RedisKeyTreeNode, event: Event) { - event.stopPropagation(); +function requestGroupDelete(node: RedisKeyTreeNode, event?: Event) { + event?.stopPropagation(); if (node.kind !== "group" || selectionBusy.value) return; const keyRaws = collectRedisGroupKeyRaws(node); if (keyRaws.length === 0) return; @@ -1143,6 +1143,34 @@ async function copyRedisKeyName(keyName: string) { } function redisKeyContextMenuItems(node: RedisKeyTreeNode): ContextMenuItem[] { + if (node.kind === "group") { + const groupPath = node.pathSegments.join(redisKeySeparator.value); + const items: ContextMenuItem[] = [ + { + label: t("redis.createKey"), + icon: Plus, + action: () => openCreateKeyDialog(`${groupPath}${redisKeySeparator.value}`), + }, + { + label: t("redis.copyKeyPath"), + icon: Copy, + action: () => copyRedisKeyName(groupPath), + }, + ]; + // Fuzzy hierarchy represents only the loaded matching subset. Keep its + // destructive actions hidden, matching the inline group-delete button. + if (!isFuzzyHierarchyView.value) { + items.push({ + label: t("redis.deleteGroupKeys"), + icon: Trash2, + variant: "destructive", + action: () => requestGroupDelete(node), + disabled: () => selectionBusy.value, + }); + } + return items; + } + const copyText = redisKeyNameCopyText(node); if (copyText === null) return []; return [ @@ -1155,8 +1183,7 @@ function redisKeyContextMenuItems(node: RedisKeyTreeNode): ContextMenuItem[] { } function onRedisRowContextMenu(event: MouseEvent, node: RedisKeyTreeNode, openContextMenu: (event: MouseEvent) => void) { - if (node.kind !== "leaf") return; - selectedKeyRaw.value = node.keyRaw; + if (node.kind === "leaf") selectedKeyRaw.value = node.keyRaw; openContextMenu(event); } @@ -1400,8 +1427,9 @@ function onCreateKeyTypeChange(type: any) { } } -function openCreateKeyDialog() { +function openCreateKeyDialog(initialKeyName = "") { resetCreateKeyForm(); + createKeyName.value = initialKeyName; showCreateKeyDialog.value = true; } @@ -2354,7 +2382,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });