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:
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 = "";
|
||||
|
||||
Reference in new issue
Block a user