feat(redis): add fetch-all keys button and incremental namespace tree merging

- Add "Fetch All" button alongside "Load More" to continuously SCAN until all keys loaded
- Add stop button and scan progress display during fetch-all
- Implement mergeKeysIntoRedisKeyTree for incremental namespace tree building
- Add toast notifications for empty form validation in key detail panel
- Add i18n translations for new UI elements (zh-CN, zh-TW, en, es)
- Add unit tests for incremental tree merge covering 6 scenarios
This commit is contained in:
t8y2 committed 2026-06-05 13:57:26 +08:00
1 parent e780e66e41
commit b4a207fc6b
8 files changed
+308 -48

No files matched your search

@@ -36,6 +36,7 @@ import {
collectExpandedGroupIds,
collectRedisGroupKeyRaws,
flattenVisibleRedisKeyTree,
mergeKeysIntoRedisKeyTree,
type RedisKeyTreeNode,
} from "@/lib/redisKeyTree";
import { classifyRedisCommandSafety } from "@/lib/redisCommandSafety";
@@ -43,9 +44,11 @@ import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from
import { formatRedisCommandResult, formatRedisStringValue } from "@/lib/redisValuePresentation";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
import { useToast } from "@/composables/useToast";
import { redisKeySearchPattern } from "@/lib/redisKeyPattern";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const editorFontFamilyStyle = useEditorFontFamilyStyle();
@@ -70,6 +73,7 @@ const flatKeys = ref<RedisKeyInfo[]>([]);
const treeKeys = ref<RedisKeyTreeNode[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const isFetchingAll = ref(false);
const rootRef = ref<HTMLElement>();
const commandTerminalRef = ref<HTMLElement>();
const searchPattern = ref("");
@@ -118,6 +122,14 @@ const searchPlaceholder = computed(() =>
const loadingEmptyText = computed(() =>
searchMode.value === "value" && valueQuery.value ? t("redis.searchingValues") : t("redis.loadingKeys"),
);
const lastTotalKeys = ref(0);
const fetchAllProgressText = computed(() => {
if (!isFetchingAll.value) return "";
if (lastTotalKeys.value > 0) {
return t("redis.fetchAllProgress", { loaded: flatKeys.value.length, total: lastTotalKeys.value });
}
return t("redis.fetchAllProgressUnknown", { loaded: flatKeys.value.length });
});
const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === selectedKeyRaw.value) ?? null);
const dangerDetails = computed(() => {
if (!pendingDanger.value) return "";
@@ -174,6 +186,22 @@ function rebuildTree(expandAll = false) {
}
}
function mergeTree(newKeys: RedisKeyInfo[]) {
if (newKeys.length === 0) return;
treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db);
const availableExpanded = collectExpandedGroupIds(treeKeys.value);
const nextExpanded = new Set<string>();
for (const id of expandedGroupIds.value) {
if (availableExpanded.has(id)) nextExpanded.add(id);
}
expandedGroupIds.value = nextExpanded;
if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) {
selectedKeyRaw.value = null;
}
}
async function fetchScanPage(): Promise<RedisScanResult> {
const pageSize = settingsStore.editorSettings.redisScanPageSize;
return searchMode.value === "value"
@@ -183,10 +211,18 @@ async function fetchScanPage(): Promise<RedisScanResult> {
function appendScanResult(result: RedisScanResult) {
const existingKeys = new Set(flatKeys.value.map((key) => key.key_raw));
flatKeys.value = [...flatKeys.value, ...result.keys.filter((key) => !existingKeys.has(key.key_raw))];
const newKeys = result.keys.filter((key) => !existingKeys.has(key.key_raw));
flatKeys.value = [...flatKeys.value, ...newKeys];
scanCursor.value = result.cursor;
hasMore.value = result.cursor !== 0;
rebuildTree(isSearchMode.value);
lastTotalKeys.value = result.total_keys;
if (treeKeys.value.length === 0) {
rebuildTree(isSearchMode.value);
} else {
mergeTree(newKeys);
}
connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, {
loaded: isSearchMode.value ? undefined : flatKeys.value.length,
total: result.total_keys,
@@ -229,6 +265,7 @@ async function fillInitialKeyBatch(requestId: number) {
async function loadKeys() {
if (!redisBrowserIsActive) return;
const requestId = ++searchRequestId;
isFetchingAll.value = false;
loading.value = true;
flatKeys.value = [];
treeKeys.value = [];
@@ -267,6 +304,26 @@ async function loadMore() {
}
}
async function fetchAll() {
if (!hasMore.value || isFetchingAll.value) return;
const requestId = searchRequestId;
isFetchingAll.value = true;
try {
while (requestId === searchRequestId && isFetchingAll.value && hasMore.value) {
const applied = await scanNextPage(requestId);
if (!applied) break;
}
} finally {
if (requestId === searchRequestId) {
isFetchingAll.value = false;
}
}
}
function stopFetchAll() {
isFetchingAll.value = false;
}
function toggleGroup(groupId: string) {
const next = new Set(expandedGroupIds.value);
if (next.has(groupId)) next.delete(groupId);
@@ -438,15 +495,18 @@ async function createRedisKey() {
const keyName = createKeyName.value.trim();
if (!keyName) {
createKeyError.value = t("redis.createKeyNameRequired");
toast(t("redis.createKeyNameRequired"), 3000);
return;
}
if (createKeyType.value === "hash" && !createKeyField.value.trim()) {
createKeyError.value = t("redis.createFieldRequired");
toast(t("redis.createFieldRequired"), 3000);
return;
}
const score = Number.parseFloat(createKeyScore.value || "0");
if (createKeyType.value === "zset" && Number.isNaN(score)) {
createKeyError.value = t("redis.createScoreInvalid");
toast(t("redis.createScoreInvalid"), 3000);
return;
}
@@ -612,6 +672,7 @@ function unregisterRedisDbFlushedListener() {
function pauseRedisBrowserBackgroundWork() {
redisBrowserIsActive = false;
searchRequestId++;
isFetchingAll.value = false;
loading.value = false;
loadingMore.value = false;
if (searchTimer) clearTimeout(searchTimer);
@@ -813,17 +874,34 @@ defineExpose({ focusSearch });
</div>
</template>
</RecycleScroller>
<div v-if="hasMore" class="shrink-0 border-t px-2 py-1.5 flex items-center justify-center">
<div v-if="hasMore && !isFetchingAll" class="shrink-0 border-t px-2 py-1.5 flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="h-7 text-xs w-full"
class="h-7 text-xs flex-1"
:disabled="loadingMore || loading"
@click="loadMore"
>
<Loader2 v-if="loadingMore" class="w-3 h-3 mr-1.5 animate-spin" />
{{ t("redis.loadMoreKeys") }}
</Button>
<Button
variant="outline"
size="sm"
class="h-7 text-xs flex-1"
:disabled="loading || !hasMore"
@click="fetchAll"
>
{{ t("redis.fetchAllKeys") }}
</Button>
</div>
<div v-if="isFetchingAll" class="shrink-0 border-t px-2 py-1.5 space-y-1">
<div class="text-xs text-muted-foreground text-center">
{{ fetchAllProgressText }}
</div>
<Button variant="destructive" size="sm" class="h-7 text-xs w-full" @click="stopFetchAll">
{{ t("redis.stopFetchAll") }}
</Button>
</div>
</div>
</Pane>
@@ -594,7 +594,10 @@ function startEditTtl() {
async function saveTtl() {
const val = ttlInput.value.trim();
const ttl = val === "" || val === "-1" ? -1 : parseInt(val, 10);
if (isNaN(ttl)) return;
if (isNaN(ttl)) {
toast(t("redis.ttlInvalid"), 3000);
return;
}
await api.redisSetTtl(props.connectionId, props.db, props.keyRaw, ttl);
editingTtl.value = false;
await load();
@@ -606,7 +609,10 @@ function cancelEditTtl() {
// Hash
async function hashSet() {
if (!newField.value) return;
if (!newField.value.trim()) {
toast(t("redis.fieldRequired"), 3000);
return;
}
await api.redisHashSet(props.connectionId, props.db, props.keyRaw, newField.value, newValue.value);
newField.value = "";
newValue.value = "";
@@ -623,7 +629,10 @@ function requestHashDel(field: string) {
// List
async function listPush() {
if (!newValue.value) return;
if (!newValue.value.trim()) {
toast(t("redis.valueRequired"), 3000);
return;
}
await api.redisListPush(props.connectionId, props.db, props.keyRaw, newValue.value);
newValue.value = "";
await load();
@@ -639,7 +648,10 @@ function requestListRemove(index: number) {
// Set
async function setAdd() {
if (!newValue.value) return;
if (!newValue.value.trim()) {
toast(t("redis.memberRequired"), 3000);
return;
}
await api.redisSetAdd(props.connectionId, props.db, props.keyRaw, newValue.value);
newValue.value = "";
await load();
@@ -655,7 +667,10 @@ function requestSetRemove(member: string) {
// ZSet
async function zsetAdd() {
if (!newValue.value) return;
if (!newValue.value.trim()) {
toast(t("redis.memberRequired"), 3000);
return;
}
const score = parseFloat(newScore.value || "0");
await api.redisZadd(props.connectionId, props.db, props.keyRaw, newValue.value, score);
newValue.value = "";