feat(redis): add key directory context menu

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-04 00:28:50 +08:00
1 parent b1fc9b15b9
commit 4dab78ac30
10 files changed
+116 -8

No files matched your search

@@ -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<HTMLElement>("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<HTMLButtonElement>('button[data-context-menu-item="redis.createKey"]');
const copyPath = host.querySelector<HTMLButtonElement>('button[data-context-menu-item="redis.copyKeyPath"]');
const deleteKeys = host.querySelector<HTMLButtonElement>('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<HTMLInputElement>('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<HTMLInputElement>("[data-redis-search-input]")!;
input.value = "folder";
input.dispatchEvent(new Event("input"));
await nextTick();
host.querySelector<HTMLButtonElement>('button[title="redis.fuzzyMatchTitle"]')!.click();
await settle();
expect(host.querySelector('button[data-context-menu-item="redis.deleteGroupKeys"]')).toBeNull();
});
});
@@ -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 });
<template #default="{ item: row }">
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu, isOpen }">
<div
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer group"
class="flex items-center gap-2 border-b px-3 text-[13px] cursor-pointer select-none group"
:class="[
isOpen || (row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40',
row.node.kind === 'leaf' ? (isLeafChecked(row.node.keyRaw) && selectedKeyRaw !== row.node.keyRaw ? 'bg-primary/10' : undefined) : groupSelectedCount(row.node) > 0 ? 'bg-primary/10' : undefined,
+2
View File
@@ -4911,6 +4911,8 @@ export default {
viewMember: "View full value",
copyMember: "Copy member",
copyKeyName: "Copy key name",
copyKeyPath: "Copy key path",
deleteGroupKeys: "Delete loaded keys",
copied: "Copied",
copyInsertStatement: "Copy Insert Statement",
copyInsertStatementBinary: "Cannot generate insert statement for binary data",
+2
View File
@@ -4708,6 +4708,8 @@ export default withEnglishFallback({
viewMember: "Ver valor completo",
copyMember: "Copiar miembro",
copyKeyName: "Copiar nombre de clave",
copyKeyPath: "Copiar ruta de clave",
deleteGroupKeys: "Eliminar claves cargadas",
copied: "Copiado",
editMember: "Editar",
jsonView: "Vista JSON",
+2
View File
@@ -4706,6 +4706,8 @@ export default withEnglishFallback({
viewMember: "Visualizza valore completo",
copyMember: "Copia membro",
copyKeyName: "Copia nome chiave",
copyKeyPath: "Copia percorso chiave",
deleteGroupKeys: "Elimina chiavi caricate",
copied: "Copiato",
copyInsertStatement: "Copia Istruzione di Inserimento",
copyInsertStatementBinary: "Impossibile generare l'istruzione di inserimento per dati binari",
+2
View File
@@ -4735,6 +4735,8 @@ export default withEnglishFallback({
viewMember: "完全な値を表示",
copyMember: "メンバーをコピー",
copyKeyName: "キー名をコピー",
copyKeyPath: "キーパスをコピー",
deleteGroupKeys: "キーを一括削除",
copied: "コピーしました",
copyInsertStatement: "INSERT文をコピー",
copyInsertStatementBinary: "バイナリデータのINSERT文は生成できません",
+2
View File
@@ -4332,6 +4332,8 @@ export default withEnglishFallback({
viewMember: "전체 값 보기",
copyMember: "멤버 복사",
copyKeyName: "키 이름 복사",
copyKeyPath: "키 경로 복사",
deleteGroupKeys: "키 일괄 삭제",
copied: "복사했습니다",
copyInsertStatement: "INSERT 구문 복사",
copyInsertStatementBinary: "바이너리 데이터에 대한 INSERT 구문을 생성할 수 없습니다",
+2
View File
@@ -4708,6 +4708,8 @@ export default withEnglishFallback({
viewMember: "Ver valor completo",
copyMember: "Copiar membro",
copyKeyName: "Copiar nome da chave",
copyKeyPath: "Copiar caminho da chave",
deleteGroupKeys: "Excluir chaves carregadas",
copied: "Copiado",
copyInsertStatement: "Copiar instrução de inserção",
copyInsertStatementBinary: "Não é possível gerar instrução de inserção para dados binários",
+2
View File
@@ -4895,6 +4895,8 @@ export default withEnglishFallback({
viewMember: "查看完整内容",
copyMember: "复制成员",
copyKeyName: "复制键名",
copyKeyPath: "复制路径",
deleteGroupKeys: "批量删除键",
copied: "已复制",
copyInsertStatement: "复制插入语句",
copyInsertStatementBinary: "二进制数据无法生成插入语句",
+2
View File
@@ -4032,6 +4032,8 @@ export default withEnglishFallback({
viewMember: "檢視完整內容",
copyMember: "複製成員",
copyKeyName: "複製鍵名",
copyKeyPath: "複製路徑",
deleteGroupKeys: "批量刪除 key",
copied: "已複製",
copyInsertStatement: "複製為 INSERT 語句",
copyInsertStatementBinary: "無法為二進位資料產生 INSERT 語句",