feat(redis): improve key browser toolbar and infinite scroll loading
This commit is contained in:
14 files changed
+254
-40
No files matched your search
@@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
updateRedisDbKeyStats: vi.fn(),
|
||||
redisScanPageSize: 100,
|
||||
infiniteScroll: false,
|
||||
infiniteScrollMaxRows: 5000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
@@ -60,6 +62,15 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
infiniteScroll: mocks.infiniteScroll,
|
||||
infiniteScrollMaxRows: mocks.infiniteScrollMaxRows,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useEditorFontFamilyStyle", () => ({
|
||||
useEditorFontFamilyStyle: () => ({}),
|
||||
}));
|
||||
@@ -346,6 +357,8 @@ function deferred<T>() {
|
||||
function resetApiMocks() {
|
||||
vi.clearAllMocks();
|
||||
mocks.redisScanPageSize = 100;
|
||||
mocks.infiniteScroll = false;
|
||||
mocks.infiniteScrollMaxRows = 5000;
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||
mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw)));
|
||||
mocks.redisSetString.mockResolvedValue(undefined);
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as api from "@/lib/backend/api";
|
||||
import type { RedisKeyInfo, RedisScanResult, RedisValue, HistoryEntry } from "@/lib/backend/api";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import {
|
||||
appendRedisKeysToTreeIndex,
|
||||
canBuildRedisFuzzyTree,
|
||||
@@ -52,10 +53,12 @@ import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyB
|
||||
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
|
||||
import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset";
|
||||
import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry";
|
||||
import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const editorFontFamilyStyle = useEditorFontFamilyStyle();
|
||||
|
||||
type RedisSearchMode = "key" | "value" | "all";
|
||||
@@ -145,6 +148,7 @@ let loadMoreOperationId = 0;
|
||||
let redisBrowserIsActive = true;
|
||||
let reloadKeysOnActivation = false;
|
||||
let redisDbFlushedListenerRegistered = false;
|
||||
let redisInfiniteScrollFrame = 0;
|
||||
const loadedKeyRaws = new Set<string>();
|
||||
let treeIndex: RedisKeyTreeIndex | null = null;
|
||||
|
||||
@@ -166,6 +170,8 @@ const searchPlaceholder = computed(() => {
|
||||
const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.value ? t(searchMode.value === "all" ? "redis.searchingAll" : "redis.searchingValues") : t("redis.loadingKeys")));
|
||||
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
|
||||
const redisScanPageSize = computed(() => connectionStore.getConfig(props.connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT);
|
||||
const redisInfiniteScrollEnabled = computed(() => settingsStore.editorSettings.infiniteScroll);
|
||||
const redisInfiniteScrollMaxKeys = computed(() => settingsStore.editorSettings.infiniteScrollMaxRows);
|
||||
watch(redisKeySeparator, () => {
|
||||
if (flatKeys.value.length === 0) return;
|
||||
if (useFlatKeySearchRows.value) {
|
||||
@@ -543,6 +549,9 @@ async function loadKeys() {
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
// 与 loadKeys 对称:组件被 keep-alive 包裹且停用后,挂起的 rAF 仍可能触发本函数,
|
||||
// 守卫掉停用态避免对隐藏组件跑一次冗余 SCAN。
|
||||
if (!redisBrowserIsActive) return;
|
||||
if (!hasMore.value || loadingMore.value) return;
|
||||
const requestId = searchRequestId;
|
||||
const operationId = ++loadMoreOperationId;
|
||||
@@ -556,6 +565,27 @@ async function loadMore() {
|
||||
}
|
||||
}
|
||||
|
||||
function onRedisKeyScroll(event: Event) {
|
||||
const scroller = event.target;
|
||||
if (!(scroller instanceof HTMLElement) || redisInfiniteScrollFrame) return;
|
||||
redisInfiniteScrollFrame = requestAnimationFrame(() => {
|
||||
redisInfiniteScrollFrame = 0;
|
||||
const shouldLoad = shouldLoadMoreRedisKeys({
|
||||
enabled: redisInfiniteScrollEnabled.value,
|
||||
hasMore: hasMore.value,
|
||||
busy: loading.value || loadingMore.value || searchPending.value || deletingKeys.value || isFetchingAll.value,
|
||||
loadedKeys: flatKeys.value.length,
|
||||
maxKeys: redisInfiniteScrollMaxKeys.value,
|
||||
scrollTop: scroller.scrollTop,
|
||||
clientHeight: scroller.clientHeight,
|
||||
scrollHeight: scroller.scrollHeight,
|
||||
});
|
||||
if (shouldLoad) {
|
||||
void loadMore().catch((error) => toast(errorMessage(error), 5000));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch-all uses large key-only SCAN pages and rebuilds the tree once at the
|
||||
// end; per-page tree sorting dominates runtime on million-key pattern scans.
|
||||
const FETCH_ALL_SCAN_COUNT = 50000;
|
||||
@@ -1360,6 +1390,10 @@ function pauseRedisBrowserBackgroundWork() {
|
||||
// keys that were never rendered.
|
||||
const discardIncompleteFetchAll = isFetchingAll.value;
|
||||
redisBrowserIsActive = false;
|
||||
// 与 onUnmounted 对称:组件被 keep-alive 包裹,停用时(onDeactivated)若不取消挂起的 rAF,
|
||||
// 帧回调仍会在隐藏组件上触发并调用 loadMore() 跑一次冗余 SCAN,故在此一并取消并置 0。
|
||||
if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame);
|
||||
redisInfiniteScrollFrame = 0;
|
||||
invalidateScanRequests();
|
||||
isFetchingAll.value = false;
|
||||
fetchAllStopRequested.value = false;
|
||||
@@ -1452,7 +1486,11 @@ onActivated(async () => {
|
||||
|
||||
onDeactivated(pauseRedisBrowserBackgroundWork);
|
||||
|
||||
onUnmounted(pauseRedisBrowserBackgroundWork);
|
||||
onUnmounted(() => {
|
||||
pauseRedisBrowserBackgroundWork();
|
||||
if (redisInfiniteScrollFrame) cancelAnimationFrame(redisInfiniteScrollFrame);
|
||||
redisInfiniteScrollFrame = 0;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.connectionId, props.db] as const,
|
||||
@@ -1497,23 +1535,23 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<Splitpanes class="redis-workspace-splitpanes h-full">
|
||||
<!-- Key tree (left) -->
|
||||
<Pane :size="36" :min-size="24">
|
||||
<div class="relative h-full flex flex-col overflow-hidden">
|
||||
<div class="redis-key-pane relative h-full flex flex-col overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="border-b px-2 py-2 shrink-0">
|
||||
<div class="flex flex-wrap items-start gap-1.5">
|
||||
<div class="flex min-w-0 flex-1 flex-wrap rounded-md border bg-muted/30 p-0.5" role="group">
|
||||
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'key' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('key')">
|
||||
<div class="redis-key-toolbar-header">
|
||||
<div class="redis-search-mode-group flex rounded-md border bg-muted/30 p-0.5" role="group">
|
||||
<button type="button" class="redis-search-mode-button h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'key' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('key')">
|
||||
{{ t("redis.searchByKey") }}
|
||||
</button>
|
||||
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
|
||||
<button type="button" class="redis-search-mode-button h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'value' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('value')">
|
||||
{{ t("redis.searchByValue") }}
|
||||
</button>
|
||||
<button type="button" class="h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
|
||||
<button type="button" class="redis-search-mode-button h-5 px-2 text-xs rounded-sm transition-colors" :class="searchMode === 'all' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="setSearchMode('all')">
|
||||
{{ t("redis.searchByAll") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||||
<span class="min-w-0 max-w-full truncate text-xs text-muted-foreground" :title="keyCountText">{{ keyCountText }}</span>
|
||||
<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 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" />
|
||||
@@ -1524,8 +1562,8 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<div class="relative min-w-[120px] flex-1 basis-[180px]">
|
||||
<div class="redis-key-search-row mt-2">
|
||||
<div class="relative min-w-0">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
v-model="searchPattern"
|
||||
@@ -1540,14 +1578,14 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
v-if="searchMode === 'key'"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 max-w-full shrink-0 px-2 text-xs"
|
||||
class="h-8 max-w-full shrink-0 whitespace-nowrap px-2 text-xs"
|
||||
:class="fuzzyKeySearch ? 'bg-accent text-accent-foreground' : 'border border-dashed border-border/70 text-muted-foreground hover:text-foreground'"
|
||||
:title="t('redis.fuzzyMatchTitle')"
|
||||
:aria-pressed="fuzzyKeySearch"
|
||||
@click="toggleFuzzyKeySearch"
|
||||
>
|
||||
<Asterisk class="h-3 w-3 mr-1" />
|
||||
{{ t("redis.fuzzyMatch") }}
|
||||
<Asterisk class="redis-fuzzy-icon h-3 w-3 mr-1" />
|
||||
<span class="redis-fuzzy-label">{{ t("redis.fuzzyMatch") }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1568,7 +1606,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>{{ loadingEmptyText }}</span>
|
||||
</div>
|
||||
<RecycleScroller v-else class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id">
|
||||
<RecycleScroller v-else class="redis-key-scroller flex-1" :items="visibleRows" :item-size="30" :buffer="600" :skip-hover="true" key-field="id" @scroll="onRedisKeyScroll">
|
||||
<template #default="{ item: row }">
|
||||
<CustomContextMenu :items="redisKeyContextMenuItems(row.node)" v-slot="{ onContextMenu }">
|
||||
<div
|
||||
@@ -1862,6 +1900,87 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redis-key-pane {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.redis-key-toolbar-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.redis-search-mode-group,
|
||||
.redis-key-toolbar-actions {
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.redis-search-mode-group {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.redis-search-mode-button {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.redis-key-count {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.redis-key-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
@container (max-width: 320px) {
|
||||
.redis-key-toolbar-header {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.redis-key-count {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.redis-key-toolbar-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 240px) {
|
||||
.redis-search-mode-group {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.redis-key-count {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.redis-key-toolbar-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.redis-fuzzy-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.redis-fuzzy-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.redis-key-scroller {
|
||||
will-change: scroll-position;
|
||||
contain: content;
|
||||
@@ -1871,6 +1990,10 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.redis-workspace-splitpanes > :deep(.splitpanes__pane:first-child) {
|
||||
min-width: min(256px, 64%);
|
||||
}
|
||||
|
||||
.redis-workspace-splitpanes :deep(.splitpanes--vertical > .splitpanes__splitter) {
|
||||
width: 1px !important;
|
||||
border-left: 0;
|
||||
|
||||
@@ -239,6 +239,29 @@ async function setStringDraft(value: string) {
|
||||
}
|
||||
|
||||
describe("RedisValueViewer expiry saving", () => {
|
||||
it("opens member UTF-8 editing from blank space without hijacking text double-clicks", async () => {
|
||||
mocks.redisGetValue.mockResolvedValueOnce(listValue());
|
||||
|
||||
mountViewer(vi.fn());
|
||||
await settle();
|
||||
Array.from(document.querySelectorAll<HTMLElement>("[data-redis-value-row]"))
|
||||
.find((row) => row.textContent?.includes("second"))!
|
||||
.click();
|
||||
await settle();
|
||||
|
||||
const viewer = document.querySelector<HTMLElement>("[data-redis-member-utf8-viewer]")!;
|
||||
const text = document.querySelector<HTMLElement>("[data-redis-member-utf8-text]")!;
|
||||
text.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
expect(document.querySelector("[data-redis-member-utf8-editor]")).toBeNull();
|
||||
|
||||
viewer.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
|
||||
await settle();
|
||||
const editor = document.querySelector<HTMLTextAreaElement>("[data-redis-member-utf8-editor]")!;
|
||||
expect(editor.value).toBe("second");
|
||||
expect(document.activeElement).toBe(editor);
|
||||
});
|
||||
|
||||
it("defaults to manual refresh without automatic value polling", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.redisGetValue.mockResolvedValueOnce(stringValue("dmFsdWU=", 60));
|
||||
|
||||
@@ -1700,6 +1700,7 @@ function startEditMember() {
|
||||
// Do not demote a retained JSON draft to utf8; save still needs compact normalization.
|
||||
if (memberDraftFormat.value !== "json") memberDraftFormat.value = "utf8";
|
||||
isEditingMember.value = true;
|
||||
nextTick(() => memberTextareaRef.value?.focus());
|
||||
}
|
||||
|
||||
function cancelEditMember() {
|
||||
@@ -3153,7 +3154,7 @@ defineExpose({ focusSearch });
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<template v-if="isEditingMember">
|
||||
<textarea ref="memberTextareaRef" v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" :readonly="savingMember" spellcheck="false" />
|
||||
<textarea ref="memberTextareaRef" data-redis-member-utf8-editor v-model="memberEditValue" class="dbx-editor-font-family min-h-0 flex-1 resize-none bg-background p-5 text-[13px] leading-6 outline-none" :readonly="savingMember" spellcheck="false" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex h-9 items-center gap-2 border-b px-5 text-xs">
|
||||
@@ -3225,6 +3226,10 @@ defineExpose({ focusSearch });
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="memberValueView === 'utf8' && canEditCurrentMemberFormat" data-redis-member-utf8-viewer class="dbx-editor-font-family min-h-0 flex-1 overflow-auto bg-background text-[13px] leading-6 cursor-text" @dblclick.self.prevent="startEditMember">
|
||||
<pre v-if="canHighlightMemberSurface" data-redis-member-utf8-text class="inline-block min-w-0 p-5 align-top select-text" :class="[detailTextClass('utf8'), redisJsonWordWrap ? 'max-w-full' : 'min-w-max']" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else data-redis-member-utf8-text class="inline-block min-w-0 p-5 align-top select-text" :class="[detailTextClass('utf8'), redisJsonWordWrap ? 'max-w-full' : 'min-w-max']">{{ detailTextForFormat(selectedMemberDetail, "utf8") }}</pre>
|
||||
</div>
|
||||
<pre v-else-if="canHighlightMemberSurface" class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)" v-html="contentSearchHighlightedHtml" />
|
||||
<pre v-else class="dbx-editor-font-family min-h-0 w-full min-w-0 max-w-full flex-1 overflow-auto bg-background p-5 text-[13px] leading-6" :class="detailTextClass(memberValueView)">{{ detailTextForFormat(selectedMemberDetail, memberValueView) }}</pre>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user