feat(redis): add key list select all
This commit is contained in:
1 parent
cf57df35da
commit
2e27f7a66a
10 files changed
+438
-85
No files matched your search
+259
-30
@@ -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)">
|
||||
|
||||
Reference in new issue
Block a user