feat(redis): add key list select all

This commit is contained in:
onenewcode authored and GitHub committed 2026-08-13 11:42:35 +08:00
1 parent cf57df35da
commit 2e27f7a66a
10 files changed
+438 -85

No files matched your search

@@ -501,14 +501,44 @@ async function submitKeySearch(value: string) {
await settle();
}
function groupCheckbox(label: string): HTMLInputElement {
function groupRow(label: string): HTMLElement {
const labelElement = Array.from(document.querySelectorAll<HTMLElement>(".dbx-editor-font-family")).find((element) => element.textContent === label);
expect(labelElement, label).toBeDefined();
const checkbox = labelElement?.closest(".group")?.querySelector<HTMLInputElement>('input[type="checkbox"]');
const row = labelElement?.closest<HTMLElement>(".group");
expect(row, label).toBeDefined();
return row!;
}
function redisCheckboxes(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('input[type="checkbox"]'));
}
function groupCheckbox(label: string): HTMLElement {
const checkbox = groupRow(label).querySelector<HTMLElement>('input[type="checkbox"]');
expect(checkbox, label).toBeDefined();
return checkbox!;
}
function leafCheckbox(label: string): HTMLElement {
const checkbox = redisCheckboxes().find((el) => el.closest(".group")?.querySelector(".dbx-editor-font-family")?.textContent === label);
expect(checkbox, label).toBeDefined();
return checkbox!;
}
function isCheckboxChecked(el: HTMLElement): boolean {
return (el as HTMLInputElement).checked;
}
function isCheckboxMixed(el: HTMLElement): boolean {
return (el as HTMLInputElement).indeterminate;
}
/** Select a folder via its left-side checkbox (same single-click path as leaf keys). */
async function selectGroup(label: string) {
groupCheckbox(label).dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await settle();
}
async function select(value: string) {
const item = requiredElement<HTMLButtonElement>(`[data-test-select-value="${value}"]`);
const root = item.closest<TestSelectRoot>("[data-test-select-root]");
@@ -1127,10 +1157,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
clickButtonWithText("redis.fuzzyMatch");
await settle();
const firstGroupCheckbox = groupCheckbox(`a\0b`);
firstGroupCheckbox.checked = true;
firstGroupCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
await selectGroup(`a\0b`);
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
expect(deleteButton).toBeDefined();
deleteButton!.click();
@@ -1163,12 +1190,9 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
clickButtonWithText("redis.fuzzyMatch");
await settle();
// Fuzzy search restores the namespace hierarchy and exposes group selection.
// Hierarchy restores leaf + group checkboxes (groups stay opacity-0 until hover/selection).
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length + 2);
const userCheckbox = groupCheckbox("user");
userCheckbox.checked = true;
userCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
await selectGroup("user");
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
@@ -1185,6 +1209,48 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(0);
});
it("selects hierarchy folders in the normal tree the same way as fuzzy groups", async () => {
const keys = [
{ key_display: "course:incr_class_id-1004", key_raw: "Y291cnNlOjEwMDQ=", key_type: "string", ttl: -1 },
{ key_display: "course:incr_class_id-172", key_raw: "Y291cnNlOjE3Mg==", key_type: "string", ttl: -1 },
{ key_display: "other:item", key_raw: "b3RoZXI6aXRlbQ==", key_type: "string", ttl: -1 },
];
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mocks.redisDeleteKeys.mockResolvedValue(2);
mountBrowser();
await settle();
// Single-click the folder checkbox (left side) selects all loaded keys under it.
await selectGroup("course");
expect(isCheckboxChecked(groupCheckbox("course"))).toBe(true);
expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2");
// Expand and uncheck children — parent must clear (not stay checked).
groupRow("course").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await settle();
for (const input of redisCheckboxes()) {
const label = input.closest(".group")?.querySelector(".dbx-editor-font-family")?.textContent;
if (label?.startsWith("incr_class_id-") && isCheckboxChecked(input)) {
input.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}
}
await settle();
expect(isCheckboxChecked(groupCheckbox("course"))).toBe(false);
expect(isCheckboxMixed(groupCheckbox("course"))).toBe(false);
expect(document.querySelector("[data-redis-batch-delete]")).toBeNull();
// Re-select the folder and delete only its keys.
await selectGroup("course");
expect(isCheckboxChecked(groupCheckbox("course"))).toBe(true);
requiredElement<HTMLButtonElement>("[data-redis-batch-delete]").click();
await settle();
requiredElement<HTMLButtonElement>("[data-test-danger-confirm]").click();
await settle();
expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, expect.arrayContaining(["Y291cnNlOjEwMDQ=", "Y291cnNlOjE3Mg=="]));
expect(mocks.redisDeleteKeys.mock.calls[0]?.[2]).not.toContain("b3RoZXI6aXRlbQ==");
});
it("falls back to flat rows at the fuzzy tree limit while retaining loaded-result delete wording", async () => {
const keys = [
{ key_display: "user:profile:email", key_raw: "cmF3LWVtYWls", key_type: "string", ttl: -1 },
@@ -1204,7 +1270,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length);
expect(document.body.textContent).toContain("redis.fuzzyTreeLimit");
requiredElement<HTMLInputElement>('input[type="checkbox"]').click();
requiredElement<HTMLElement>('input[type="checkbox"]').click();
await settle();
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
expect(deleteButton).toBeDefined();
@@ -1229,16 +1295,13 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
clickButtonWithText("redis.fuzzyMatch");
await settle();
const userCheckbox = groupCheckbox("user");
userCheckbox.checked = true;
userCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
await selectGroup("user");
clickButtonWithText("redis.loadMoreKeys");
await settle();
const updatedUserCheckbox = groupCheckbox("user");
expect(updatedUserCheckbox.checked).toBe(false);
expect(updatedUserCheckbox.indeterminate).toBe(true);
expect(isCheckboxChecked(updatedUserCheckbox)).toBe(false);
expect(isCheckboxMixed(updatedUserCheckbox)).toBe(true);
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "1");
expect(deleteButton).toBeDefined();
deleteButton!.click();
@@ -1265,10 +1328,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
clickButtonWithText("redis.fuzzyMatch");
await settle();
const batchCheckbox = groupCheckbox("batch");
batchCheckbox.checked = true;
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
await selectGroup("batch");
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
deleteButton!.click();
@@ -1302,10 +1362,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
clickButtonWithText("redis.fuzzyMatch");
await settle();
const batchCheckbox = groupCheckbox("batch");
batchCheckbox.checked = true;
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
await selectGroup("batch");
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
deleteButton!.click();
@@ -1339,10 +1396,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
clickButtonWithText("redis.fuzzyMatch");
await settle();
const batchCheckbox = groupCheckbox("batch");
batchCheckbox.checked = true;
batchCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
await settle();
await selectGroup("batch");
const deleteButton = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(keys.length));
expect(deleteButton).toBeDefined();
deleteButton!.click();
@@ -1394,4 +1448,179 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
expect(document.body.textContent).not.toContain("buffered");
expect(freshPageRequests).toBeGreaterThan(0);
});
it("selects all loaded keys from the toolbar and clears the multi-selection", async () => {
const keys = [
{ key_display: "alpha", key_raw: "YWxwaGE=", key_type: "string", ttl: -1 },
{ key_display: "bravo", key_raw: "YnJhdm8=", key_type: "string", ttl: -1 },
{ key_display: "charlie", key_raw: "Y2hhcmxpZQ==", key_type: "string", ttl: -1 },
];
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mountBrowser();
await settle();
const selectAll = requiredElement<HTMLButtonElement>("[data-redis-select-all]");
selectAll.click();
await settle();
const checkboxes = Array.from(document.querySelectorAll<HTMLElement>('input[type="checkbox"]'));
expect(checkboxes).toHaveLength(keys.length);
expect(checkboxes.every((checkbox) => isCheckboxChecked(checkbox as HTMLElement))).toBe(true);
expect(document.querySelector("[data-redis-select-all]")).toBeNull();
expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain(String(keys.length));
expect(document.activeElement).toBe(document.querySelector(".redis-key-pane"));
requiredElement<HTMLButtonElement>("[data-redis-deselect-all]").click();
await settle();
expect(Array.from(document.querySelectorAll<HTMLElement>('input[type="checkbox"]')).every((checkbox) => !isCheckboxChecked(checkbox))).toBe(true);
expect(document.querySelector("[data-redis-batch-delete]")).toBeNull();
expect(document.querySelector("[data-redis-select-all]")).not.toBeNull();
expect(document.activeElement).toBe(document.querySelector(".redis-key-pane"));
});
it("fetches remaining scan pages before selecting all keys", async () => {
const first = { key_display: "first", key_raw: "Zmlyc3Q=", key_type: "string", ttl: -1 };
const second = { key_display: "second", key_raw: "c2Vjb25k", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
if (cursor === 0) return Promise.resolve({ cursor: 1, keys: [first], total_keys: 2 });
return Promise.resolve({ cursor: 0, keys: [second], total_keys: 0 });
});
mountBrowser();
await settle();
expect(document.body.textContent).toContain("redis.loadMoreKeys");
const keyPane = requiredElement<HTMLElement>(".redis-key-pane");
keyPane.focus();
keyPane.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2"));
expect(document.body.textContent).toContain("second");
expect(Array.from(document.querySelectorAll<HTMLElement>('input[type="checkbox"]')).every(isCheckboxChecked)).toBe(true);
expect(document.querySelector("[data-redis-select-all]")).toBeNull();
});
it("does not select stale keys when a search changes during select-all scanning", async () => {
const oldPage = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
const oldKey = { key_display: "old", key_raw: "b2xk", key_type: "string", ttl: -1 };
const freshKey = { key_display: "fresh", key_raw: "ZnJlc2g=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
if (pattern === "*" && cursor === 0) return Promise.resolve({ cursor: 1, keys: [oldKey], total_keys: 2 });
if (pattern === "*") return oldPage.promise;
return Promise.resolve({ cursor: 0, keys: [freshKey], total_keys: 1 });
});
mountBrowser();
await settle();
requiredElement<HTMLButtonElement>("[data-redis-select-all]").click();
await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.length).toBeGreaterThan(1));
const input = requiredElement<HTMLInputElement>("[data-redis-search-input]");
input.value = "fresh";
input.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
oldPage.resolve({ cursor: 0, keys: [oldKey], total_keys: 0 });
await settle();
await vi.waitFor(() => expect(document.body.textContent).toContain("fresh"));
expect(document.body.textContent).not.toContain("old");
expect(document.querySelector("[data-redis-batch-delete]")).toBeNull();
});
it("does not downgrade Ctrl+A to a partial selection when fetch-all is stopped", async () => {
const continuation = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
const first = { key_display: "first", key_raw: "Zmlyc3Q=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => {
if (cursor === 0) return Promise.resolve({ cursor: 1, keys: [first], total_keys: 2 });
return continuation.promise;
});
mountBrowser();
await settle();
requiredElement<HTMLButtonElement>("[data-redis-select-all]").click();
await vi.waitFor(() => expect(document.body.textContent).toContain("redis.stopFetchAll"));
clickButtonWithText("redis.stopFetchAll");
continuation.resolve({ cursor: 1, keys: [], total_keys: 0 });
await settle();
expect(document.querySelector("[data-redis-batch-delete]")).toBeNull();
expect(document.querySelector("[data-redis-select-all]")).not.toBeNull();
});
it("shows a native checked mark after selecting a single leaf key", async () => {
const keys = [
{ key_display: "solo-a", key_raw: "c29sby1h", key_type: "string", ttl: -1 },
{ key_display: "solo-b", key_raw: "c29sby1i", key_type: "string", ttl: -1 },
];
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mountBrowser();
await settle();
const leafA = leafCheckbox("solo-a");
expect(leafA.className).toContain("accent-primary");
expect(leafA.className).toContain("h-3.5");
leafA.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await settle();
expect(isCheckboxChecked(leafCheckbox("solo-a"))).toBe(true);
expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("1");
});
it("keeps parent and child checkbox DOM state in sync", async () => {
const keys = [
{ key_display: "pack:user_select", key_raw: "cGFjazp1c2VyX3NlbGVjdA==", key_type: "string", ttl: -1 },
{ key_display: "other:item", key_raw: "b3RoZXI6aXRlbQ==", key_type: "string", ttl: -1 },
];
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mountBrowser();
await settle();
// Select parent folder → child leaf must also appear checked.
await selectGroup("pack");
groupRow("pack").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await settle();
const child = leafCheckbox("user_select");
expect(isCheckboxChecked(groupCheckbox("pack"))).toBe(true);
expect(isCheckboxChecked(child)).toBe(true);
// Uncheck child → parent must clear.
child.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await settle();
expect(isCheckboxChecked(leafCheckbox("user_select"))).toBe(false);
expect(isCheckboxChecked(groupCheckbox("pack"))).toBe(false);
expect(isCheckboxMixed(groupCheckbox("pack"))).toBe(false);
});
it("supports Shift multi-line selection and Ctrl/Cmd+A on the key list", async () => {
const keys = [
{ key_display: "k1", key_raw: "azE=", key_type: "string", ttl: -1 },
{ key_display: "k2", key_raw: "azI=", key_type: "string", ttl: -1 },
{ key_display: "k3", key_raw: "azM=", key_type: "string", ttl: -1 },
{ key_display: "k4", key_raw: "azQ=", key_type: "string", ttl: -1 },
];
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length });
mountBrowser();
await settle();
const checkboxes = Array.from(document.querySelectorAll<HTMLElement>('input[type="checkbox"]'));
expect(checkboxes).toHaveLength(keys.length);
checkboxes[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await settle();
expect(isCheckboxChecked(checkboxes[0]!)).toBe(true);
checkboxes[2]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true }));
await settle();
expect(checkboxes.map((checkbox) => isCheckboxChecked(checkbox as HTMLElement))).toEqual([true, true, true, false]);
expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("3");
const keyPane = requiredElement<HTMLElement>(".redis-key-pane");
keyPane.focus();
keyPane.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true }));
await settle();
expect(checkboxes.every((checkbox) => isCheckboxChecked(checkbox as HTMLElement))).toBe(true);
expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("4");
keyPane.dispatchEvent(new KeyboardEvent("keydown", { key: "a", metaKey: true, bubbles: true, cancelable: true }));
await settle();
expect(checkboxes.every((checkbox) => !isCheckboxChecked(checkbox as HTMLElement))).toBe(true);
});
});
@@ -102,6 +102,7 @@ const isFetchingAll = ref(false);
const fetchAllStopRequested = ref(false);
const fetchAllLoadedCount = ref(0);
const rootRef = ref<HTMLElement>();
const keyPaneRef = ref<HTMLElement>();
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
const commandTerminalRef = ref<HTMLElement>();
const searchPattern = ref("");
@@ -112,6 +113,9 @@ const hasMore = ref(false);
const scanCursor = ref(0);
const expandedGroupIds = ref<Set<string>>(new Set());
const checkedKeys = ref<Set<string>>(new Set());
/** Bumped when selection or tree counts change so virtualized rows re-evaluate state. */
const selectionEpoch = ref(0);
const selectionAnchorRowId = ref<string | null>(null);
const selectedGroupLeafCounts = shallowRef<Map<string, number>>(new Map());
const deletingKeys = ref(false);
const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[]; loadedSearchResults: boolean } | { kind: "command"; command: string } | null>(null);
@@ -179,6 +183,9 @@ const fuzzyTreeLimitReached = computed(() => isFuzzyKeySearch.value && !canBuild
const useFlatKeySearchRows = computed(() => (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value);
const isFuzzyHierarchyView = computed(() => isFuzzyKeySearch.value && !fuzzyTreeLimitReached.value);
const selectionBusy = computed(() => deletingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value);
// checkedKeys is always a subset of loaded keys, so size equality is enough.
const allLoadedKeysSelected = computed(() => flatKeys.value.length > 0 && checkedKeys.value.size === flatKeys.value.length);
const allKeysSelected = computed(() => allLoadedKeysSelected.value && !hasMore.value);
const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
@@ -311,64 +318,145 @@ let commandHistoryId = 0;
function resetCheckedKeys() {
checkedKeys.value = new Set();
selectedGroupLeafCounts.value = new Map();
selectionAnchorRowId.value = null;
selectionEpoch.value++;
}
/** Parent folder selected-count derived only from currently checked leaves. */
function groupLeafCountsFromChecked(checked: ReadonlySet<string>): Map<string, number> {
const nextCounts = new Map<string, number>();
const ancestors = treeIndex?.ancestorGroupIdsByKeyRaw;
if (!ancestors) return nextCounts;
for (const keyRaw of checked) {
for (const groupId of ancestors.get(keyRaw) ?? []) {
nextCounts.set(groupId, (nextCounts.get(groupId) ?? 0) + 1);
}
}
return nextCounts;
}
function refreshSelectedGroupLeafCounts() {
const nextChecked = new Set<string>();
const nextCounts = new Map<string, number>();
for (const keyRaw of checkedKeys.value) {
if (!loadedKeyRaws.has(keyRaw)) continue;
nextChecked.add(keyRaw);
for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) {
nextCounts.set(groupId, (nextCounts.get(groupId) ?? 0) + 1);
}
if (loadedKeyRaws.has(keyRaw)) nextChecked.add(keyRaw);
}
checkedKeys.value = nextChecked;
selectedGroupLeafCounts.value = nextCounts;
selectedGroupLeafCounts.value = groupLeafCountsFromChecked(nextChecked);
selectionEpoch.value++;
}
function setKeysChecked(keyRaws: Iterable<string>, checked: boolean) {
const nextChecked = new Set(checkedKeys.value);
const nextCounts = new Map(selectedGroupLeafCounts.value);
let changed = false;
for (const keyRaw of keyRaws) {
if (!loadedKeyRaws.has(keyRaw)) continue;
const wasChecked = nextChecked.has(keyRaw);
if (wasChecked === checked) continue;
if (!loadedKeyRaws.has(keyRaw) && !nextChecked.has(keyRaw)) continue;
if (nextChecked.has(keyRaw) === checked) continue;
if (checked) nextChecked.add(keyRaw);
else nextChecked.delete(keyRaw);
const delta = checked ? 1 : -1;
for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) {
const nextCount = (nextCounts.get(groupId) ?? 0) + delta;
if (nextCount > 0) nextCounts.set(groupId, nextCount);
else nextCounts.delete(groupId);
}
changed = true;
}
if (!changed) return;
checkedKeys.value = nextChecked;
selectedGroupLeafCounts.value = nextCounts;
// Always recompute parent counts from the full leaf set so parent/child never drift.
selectedGroupLeafCounts.value = groupLeafCountsFromChecked(nextChecked);
selectionEpoch.value++;
}
function setKeyChecked(keyRaw: string, checked: boolean) {
setKeysChecked([keyRaw], checked);
function nodeKeyRaws(node: RedisKeyTreeNode): string[] {
return node.kind === "leaf" ? [node.keyRaw] : collectRedisGroupKeyRaws(node);
}
function isGroupFullyChecked(group: RedisKeyTreeGroupNode): boolean {
return group.loadedLeafCount > 0 && selectedGroupLeafCounts.value.get(group.id) === group.loadedLeafCount;
function focusKeyPane() {
keyPaneRef.value?.focus({ preventScroll: true });
}
function groupSelectedCount(group: RedisKeyTreeGroupNode): number {
void selectionEpoch.value;
return selectedGroupLeafCounts.value.get(group.id) ?? 0;
}
function isNodeChecked(node: RedisKeyTreeNode): boolean {
void selectionEpoch.value;
if (node.kind === "leaf") return checkedKeys.value.has(node.keyRaw);
return node.loadedLeafCount > 0 && groupSelectedCount(node) === node.loadedLeafCount;
}
function isGroupPartiallyChecked(group: RedisKeyTreeGroupNode): boolean {
const selectedCount = selectedGroupLeafCounts.value.get(group.id) ?? 0;
void selectionEpoch.value;
const selectedCount = groupSelectedCount(group);
return selectedCount > 0 && selectedCount < group.loadedLeafCount;
}
function setGroupChecked(group: RedisKeyTreeGroupNode, checked: boolean) {
setKeysChecked(collectRedisGroupKeyRaws(group), checked);
function isLeafChecked(keyRaw: string): boolean {
void selectionEpoch.value;
return checkedKeys.value.has(keyRaw);
}
/** Check/uncheck a leaf or folder; Shift expands an inclusive visible-row range. */
function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) {
// Let the native checkbox handle ordinary clicks; custom range selection owns Shift clicks.
if (event.shiftKey) event.preventDefault();
event.stopPropagation();
if (selectionBusy.value) return;
focusKeyPane();
if (event.shiftKey) {
const rows = visibleRows.value;
const to = rows.findIndex((row) => row.node.id === node.id);
if (to < 0) return;
let from = selectionAnchorRowId.value ? rows.findIndex((row) => row.id === selectionAnchorRowId.value) : to;
if (from < 0) from = to;
const keyRaws: string[] = [];
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) keyRaws.push(...nodeKeyRaws(rows[i].node));
setKeysChecked(keyRaws, true);
if (!selectionAnchorRowId.value) selectionAnchorRowId.value = node.id;
return;
}
setKeysChecked(nodeKeyRaws(node), !isNodeChecked(node));
selectionAnchorRowId.value = node.id;
}
function selectAllLoadedKeys() {
if (selectionBusy.value || loadedKeyRaws.size === 0) return;
focusKeyPane();
setKeysChecked(loadedKeyRaws, true);
selectionAnchorRowId.value = visibleRows.value[0]?.id ?? null;
}
async function selectAllKeys() {
if (selectionBusy.value || (loadedKeyRaws.size === 0 && !hasMore.value)) return;
const requestId = searchRequestId;
if (hasMore.value) {
let fetchedAll = false;
try {
fetchedAll = await fetchAll();
} catch (error) {
toast(errorMessage(error), 5000);
return;
}
// A search or scope change may have replaced the result while SCAN was running.
// If the user stopped the scan, do not silently downgrade Ctrl+A to a partial selection.
if (!fetchedAll || requestId !== searchRequestId || !redisBrowserIsActive) return;
}
selectAllLoadedKeys();
}
function clearAllCheckedKeys() {
if (selectionBusy.value || checkedKeys.value.size === 0) return;
focusKeyPane();
resetCheckedKeys();
}
function onKeyPaneKeydown(event: KeyboardEvent) {
if (selectionBusy.value || event.isComposing) return;
if ((event.target as HTMLElement | null)?.closest("input, textarea, select, [contenteditable='true']")) return;
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "a") return;
event.preventDefault();
if (allKeysSelected.value) clearAllCheckedKeys();
else void selectAllKeys();
}
function rebuildTree(expandAll = false) {
@@ -418,6 +506,8 @@ function mergeTree(newKeys: RedisKeyInfo[]) {
for (const id of addedGroupIds) nextExpanded.add(id);
}
expandedGroupIds.value = nextExpanded;
// loadedLeafCount changed — re-evaluate parent checked/partial state in the UI.
if (checkedKeys.value.size > 0) selectionEpoch.value++;
}
function invalidateScanRequests(): number {
@@ -607,14 +697,15 @@ function onRedisKeyScroll(event: Event) {
const FETCH_ALL_SCAN_COUNT = 50000;
const FETCH_ALL_BATCH_ITERATIONS = 8;
async function fetchAll() {
if (!hasMore.value || isFetchingAll.value) return;
async function fetchAll(): Promise<boolean> {
if (!hasMore.value || isFetchingAll.value) return false;
const requestId = searchRequestId;
const bufferedKeys: RedisKeyInfo[] = [];
isFetchingAll.value = true;
fetchAllStopRequested.value = false;
fetchAllLoadedCount.value = flatKeys.value.length;
let changed = false;
let completed = false;
try {
while (requestId === searchRequestId && !fetchAllStopRequested.value && hasMore.value) {
const result = await fetchScanBatchPage(FETCH_ALL_BATCH_ITERATIONS, {
@@ -625,6 +716,7 @@ async function fetchAll() {
changed = appendScanResult(result, { updateTree: false, buffer: bufferedKeys }) > 0 || changed;
fetchAllLoadedCount.value = flatKeys.value.length + bufferedKeys.length;
}
completed = requestId === searchRequestId && !fetchAllStopRequested.value && !hasMore.value;
} finally {
if (requestId === searchRequestId) {
if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys];
@@ -642,6 +734,7 @@ async function fetchAll() {
fetchAllLoadedCount.value = 0;
}
}
return completed;
}
function stopFetchAll() {
@@ -655,12 +748,21 @@ function toggleGroup(groupId: string) {
expandedGroupIds.value = next;
}
function onRowClick(node: RedisKeyTreeNode) {
function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) {
if (event && !selectionBusy.value && (event.shiftKey || event.ctrlKey || event.metaKey)) {
toggleNodeCheck(node, event);
if (node.kind === "leaf") {
focusKeyPane();
selectedKeyRaw.value = node.keyRaw;
activeSidePanel.value = "detail";
}
return;
}
if (node.kind === "group") {
toggleGroup(node.id);
return;
}
focusKeyPane();
selectedKeyRaw.value = node.keyRaw;
activeSidePanel.value = "detail";
}
@@ -717,12 +819,6 @@ function onKeyLoaded(value: RedisValue) {
}
}
function toggleCheck(keyRaw: string, event: Event) {
event.stopPropagation();
if (selectionBusy.value) return;
setKeyChecked(keyRaw, !checkedKeys.value.has(keyRaw));
}
function requestBatchDelete() {
if (checkedKeys.value.size === 0 || selectionBusy.value) return;
pendingDanger.value = {
@@ -1729,7 +1825,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<Splitpanes class="redis-workspace-splitpanes h-full">
<!-- Key tree (left) -->
<Pane :size="36" :min-size="24">
<div class="redis-key-pane relative h-full flex flex-col overflow-hidden">
<div ref="keyPaneRef" class="redis-key-pane relative h-full flex flex-col overflow-hidden outline-none" tabindex="0" @keydown="onKeyPaneKeydown">
<!-- Toolbar -->
<div class="border-b px-2 py-2 shrink-0">
<div class="redis-key-toolbar-header">
@@ -1746,7 +1842,9 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
<span class="redis-key-count truncate text-xs text-muted-foreground" :title="keyCountText">{{ keyCountText }}</span>
<div class="redis-key-toolbar-actions flex items-center justify-end gap-1">
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" @click="requestBatchDelete"> <Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }} </Button>
<Button v-if="(flatKeys.length > 0 || hasMore) && !allKeysSelected" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" :title="t('redis.selectAllLoadedTitle')" data-redis-select-all @click="selectAllKeys">{{ t("redis.selectAllLoaded") }}</Button>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="selectionBusy" data-redis-deselect-all @click="clearAllCheckedKeys">{{ t("redis.deselectAll") }}</Button>
<Button v-if="checkedKeys.size > 0" variant="ghost" size="sm" class="h-6 shrink-0 text-xs text-destructive" :disabled="selectionBusy" data-redis-batch-delete @click="requestBatchDelete"><Trash2 class="w-3 h-3 mr-1" />{{ checkedKeys.size }}</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="deletingKeys" @click="loadKeys">
<Loader2 v-if="loading" class="h-3 w-3 animate-spin" />
<RefreshCw v-else class="h-3 w-3" />
@@ -1805,45 +1903,47 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
<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="isOpen || (row.node.kind === 'leaf' && selectedKeyRaw === row.node.keyRaw) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'"
: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,
]"
:style="{ height: '30px' }"
@click="onRowClick(row.node)"
@click="onRowClick(row.node, $event)"
@contextmenu="(event) => onRedisRowContextMenu(event, row.node, onContextMenu)"
>
<div class="min-w-0 flex flex-1 items-center gap-1 overflow-hidden" :style="{ paddingLeft: `${12 + row.depth * 16}px` }">
<template v-if="row.node.kind === 'group'">
<input
v-if="isFuzzyHierarchyView"
type="checkbox"
class="h-3.5 w-3.5 shrink-0 accent-primary cursor-pointer"
:checked="isGroupFullyChecked(row.node)"
:checked="isNodeChecked(row.node)"
:indeterminate="isGroupPartiallyChecked(row.node)"
:disabled="selectionBusy"
:aria-label="t('redis.selectLoadedGroupKeys', { count: row.node.loadedLeafCount })"
@click.stop
@change="setGroupChecked(row.node, ($event.target as HTMLInputElement).checked)"
:disabled="selectionBusy"
:data-redis-group="row.node.id"
@click="toggleNodeCheck(row.node, $event)"
/>
<component :is="expandedGroupIds.has(row.node.id) ? ChevronDown : ChevronRight" class="w-3 h-3 shrink-0 text-muted-foreground" />
<component :is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed" class="w-3 h-3 shrink-0 text-amber-500" />
<component :is="expandedGroupIds.has(row.node.id) ? FolderOpen : FolderClosed" class="h-3.5 w-3.5 shrink-0 text-amber-500" />
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
<span class="text-muted-foreground ml-1" :title="isFuzzyHierarchyView ? t('redis.loadedMatchingKeys', { count: row.node.loadedLeafCount }) : undefined">({{ row.node.loadedLeafCount }})</span>
</template>
<template v-else>
<span class="relative flex h-4 w-4 shrink-0 items-center justify-center">
<KeyRound class="h-3.5 w-3.5 text-muted-foreground/70 transition-opacity group-hover:opacity-0" :class="{ 'opacity-0': checkedKeys.has(row.node.keyRaw) }" />
<KeyRound class="h-3.5 w-3.5 text-muted-foreground/70 transition-opacity group-hover:opacity-0" :class="{ 'opacity-0': isLeafChecked(row.node.keyRaw) }" />
<input
type="checkbox"
class="absolute h-3.5 w-3.5 accent-primary cursor-pointer opacity-0 group-hover:opacity-100"
:class="{ 'opacity-100': checkedKeys.has(row.node.keyRaw) }"
:checked="checkedKeys.has(row.node.keyRaw)"
:class="{ 'opacity-100': isLeafChecked(row.node.keyRaw) }"
:disabled="selectionBusy"
@click="toggleCheck(row.node.keyRaw, $event)"
:checked="isLeafChecked(row.node.keyRaw)"
:data-redis-leaf="row.node.keyRaw"
@click="toggleNodeCheck(row.node, $event)"
/>
</span>
<span class="dbx-editor-font-family truncate">{{ row.node.label }}</span>
</template>
</div>
<div class="flex shrink-0 items-center justify-end gap-1">
<Badge v-if="row.node.kind === 'leaf' && row.node.keyType" variant="outline" class="text-xs px-1.5 py-0" :class="typeColor(row.node.keyType)">{{ row.node.keyType }}</Badge>
<Button v-if="row.node.kind === 'group' && !isFuzzyHierarchyView" variant="ghost" size="icon" class="h-5 w-5 shrink-0 text-destructive opacity-0 group-hover:opacity-100" :title="t('redis.deleteGroup')" :disabled="selectionBusy" @click="requestGroupDelete(row.node, $event)">