feat(redis): improve key browser toolbar and infinite scroll loading
This commit is contained in:
parent
b1fcb23625
commit
328a2aa107
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -5169,9 +5169,9 @@ export default {
|
|||
infiniteScroll: "Infinite scroll loading",
|
||||
autoCalculateTotalRows: "Auto-calculate total row count",
|
||||
autoCalculateTotalRowsDescription: "Run COUNT(*) automatically after each query to show the total matching rows. Off by default to keep large queries fast — calculate it on demand from the result footer.",
|
||||
infiniteScrollDescription: "Automatically load the next page of data when scrolling to the bottom of the table.",
|
||||
infiniteScrollMaxRows: "Infinite scroll max rows",
|
||||
infiniteScrollMaxRowsDescription: "Maximum number of rows to load in infinite scroll mode (1000–50000).",
|
||||
infiniteScrollDescription: "Automatically load the next batch when scrolling to the bottom.",
|
||||
infiniteScrollMaxRows: "Infinite scroll max items",
|
||||
infiniteScrollMaxRowsDescription: "Maximum number of items to load automatically in infinite scroll mode (1000–50000).",
|
||||
regexMaxMatchCount: "Maximum select-all matches",
|
||||
regexMaxMatchCountDescription: "Limits the number of selections created by Select All Matches; search counting remains complete (100–10000).",
|
||||
tableColumnTemplateFields: "New Table Preset Fields",
|
||||
|
|
|
|||
|
|
@ -4930,9 +4930,9 @@ export default withEnglishFallback({
|
|||
infiniteScroll: "Carga de desplazamiento infinito",
|
||||
autoCalculateTotalRows: "Calcular automáticamente el total de filas",
|
||||
autoCalculateTotalRowsDescription: "Ejecuta COUNT(*) automáticamente tras cada consulta para mostrar el total de filas coincidentes. Desactivado por defecto para mantener rápidas las consultas grandes; puedes calcularlo cuando quieras desde el pie de resultados.",
|
||||
infiniteScrollDescription: "Carga automáticamente la siguiente página de datos al desplazarse hasta el final de la tabla.",
|
||||
infiniteScrollMaxRows: "Máximo de filas en desplazamiento infinito",
|
||||
infiniteScrollMaxRowsDescription: "Número máximo de filas a cargar en modo desplazamiento infinito (1000–50000).",
|
||||
infiniteScrollDescription: "Carga automáticamente el siguiente lote al desplazarse hasta el final.",
|
||||
infiniteScrollMaxRows: "Máximo de elementos en desplazamiento infinito",
|
||||
infiniteScrollMaxRowsDescription: "Número máximo de elementos que se cargarán automáticamente en modo desplazamiento infinito (1000–50000).",
|
||||
regexMaxMatchCount: "Máximo de coincidencias al seleccionar todas",
|
||||
regexMaxMatchCountDescription: "Limita el número de selecciones creadas por Seleccionar todas las coincidencias; el conteo de búsqueda permanece completo (100–10000).",
|
||||
tableColumnTemplateFields: "Campos predefinidos para tablas nuevas",
|
||||
|
|
|
|||
|
|
@ -4930,9 +4930,9 @@ export default withEnglishFallback({
|
|||
infiniteScroll: "Caricamento a scorrimento infinito",
|
||||
autoCalculateTotalRows: "Calcola automaticamente il totale delle righe",
|
||||
autoCalculateTotalRowsDescription: "Esegue COUNT(*) automaticamente dopo ogni query per mostrare il totale delle righe corrispondenti. Disattivato per impostazione predefinita per mantenere veloci le query grandi; puoi calcolarlo all'occorrenza dal piè di pagina dei risultati.",
|
||||
infiniteScrollDescription: "Carica automaticamente la pagina successiva dei dati quando scorri fino in fondo alla tabella.",
|
||||
infiniteScrollMaxRows: "Righe max a scorrimento infinito",
|
||||
infiniteScrollMaxRowsDescription: "Numero massimo di righe da caricare in modalità scorrimento infinito (1000–50000).",
|
||||
infiniteScrollDescription: "Carica automaticamente il blocco successivo quando scorri fino in fondo.",
|
||||
infiniteScrollMaxRows: "Elementi max a scorrimento infinito",
|
||||
infiniteScrollMaxRowsDescription: "Numero massimo di elementi da caricare automaticamente in modalità scorrimento infinito (1000–50000).",
|
||||
regexMaxMatchCount: "Numero massimo di corrispondenze per Seleziona tutte",
|
||||
regexMaxMatchCountDescription: "Limita il numero di selezioni create da Seleziona tutte le corrispondenze; il conteggio della ricerca rimane completo (100–10000).",
|
||||
tableColumnTemplateFields: "Campi predefiniti per nuove tabelle",
|
||||
|
|
|
|||
|
|
@ -5349,9 +5349,9 @@ export default withEnglishFallback({
|
|||
infiniteScroll: "無限スクロール読み込み",
|
||||
autoCalculateTotalRows: "総行数を自動計算",
|
||||
autoCalculateTotalRowsDescription: "クエリごとに COUNT(*) を自動実行し、一致する総行数を表示します。大きなクエリを高速に保つため既定はオフです。結果フッターから必要に応じて計算できます。",
|
||||
infiniteScrollDescription: "テーブルのスクロール時に次のページのデータを自動読み込みします。",
|
||||
infiniteScrollMaxRows: "無限スクロールの最大行数",
|
||||
infiniteScrollMaxRowsDescription: "無限スクロールモードで読み込む最大行数(1000〜50000)。",
|
||||
infiniteScrollDescription: "末尾までスクロールしたときに、次のデータを自動的に読み込みます。",
|
||||
infiniteScrollMaxRows: "無限スクロールの最大項目数",
|
||||
infiniteScrollMaxRowsDescription: "無限スクロールモードで自動読み込みする項目の最大数(1000〜50000)。",
|
||||
regexMaxMatchCount: "すべての一致を選択する最大数",
|
||||
regexMaxMatchCountDescription: "「すべての一致を選択」で一度に作成する選択範囲の数を制限します。検索の一致数は完全に集計されます(100〜10000)。",
|
||||
exportRowLimitEnabled: "エクスポート行数を制限",
|
||||
|
|
|
|||
|
|
@ -4685,9 +4685,9 @@ export default withEnglishFallback({
|
|||
infiniteScroll: "무한 스크롤 로딩",
|
||||
autoCalculateTotalRows: "전체 행 수 자동 집계",
|
||||
autoCalculateTotalRowsDescription: "각 쿼리 후 자동으로 COUNT(*)를 실행하여 일치하는 전체 행 수를 표시합니다. 대규모 쿼리를 빠르게 유지하기 위해 기본적으로 꺼져 있으며, 결과 푸터에서 요청 시 집계할 수 있습니다.",
|
||||
infiniteScrollDescription: "테이블 하단으로 스크롤할 때 다음 데이터 페이지를 자동으로 불러옵니다.",
|
||||
infiniteScrollMaxRows: "무한 스크롤 최대 행",
|
||||
infiniteScrollMaxRowsDescription: "무한 스크롤 모드에서 로드할 최대 행 수 (1000–50000).",
|
||||
infiniteScrollDescription: "목록 하단으로 스크롤할 때 다음 데이터 묶음을 자동으로 불러옵니다.",
|
||||
infiniteScrollMaxRows: "무한 스크롤 최대 항목 수",
|
||||
infiniteScrollMaxRowsDescription: "무한 스크롤 모드에서 자동으로 불러올 항목의 최대 수 (1000–50000).",
|
||||
tableColumnTemplateFields: "새 테이블 미리 정의된 필드",
|
||||
tableColumnTemplateFieldsDescription: "데이터베이스 유형을 선택한 다음 새 테이블을 만들 때 사용할 미리 정의된 필드 타입을 구성하세요.",
|
||||
tableColumnTemplateAdd: "필드 추가",
|
||||
|
|
|
|||
|
|
@ -4932,9 +4932,9 @@ export default withEnglishFallback({
|
|||
infiniteScroll: "Carregamento por rolagem infinita",
|
||||
autoCalculateTotalRows: "Calcular automaticamente o total de linhas",
|
||||
autoCalculateTotalRowsDescription: "Executa COUNT(*) automaticamente após cada consulta para mostrar o total de linhas correspondentes. Desativado por padrão para manter consultas grandes rápidas; você pode calculá-lo quando quiser no rodapé dos resultados.",
|
||||
infiniteScrollDescription: "Carregar automaticamente a próxima página de dados ao rolar até o final da tabela.",
|
||||
infiniteScrollMaxRows: "Máximo de linhas em rolagem infinita",
|
||||
infiniteScrollMaxRowsDescription: "Número máximo de linhas a carregar no modo de rolagem infinita (1000–50000).",
|
||||
infiniteScrollDescription: "Carregar automaticamente o próximo lote ao rolar até o final.",
|
||||
infiniteScrollMaxRows: "Máximo de itens em rolagem infinita",
|
||||
infiniteScrollMaxRowsDescription: "Número máximo de itens a carregar automaticamente no modo de rolagem infinita (1000–50000).",
|
||||
regexMaxMatchCount: "Máximo de correspondências para selecionar todas",
|
||||
regexMaxMatchCountDescription: "Limita o número de seleções criadas por Selecionar todas as correspondências; a contagem da pesquisa permanece completa (100–10000).",
|
||||
tableColumnTemplateFields: "Campos predefinidos para novas tabelas",
|
||||
|
|
|
|||
|
|
@ -5166,9 +5166,9 @@ export default withEnglishFallback({
|
|||
infiniteScroll: "无限滚动加载",
|
||||
autoCalculateTotalRows: "自动统计总行数",
|
||||
autoCalculateTotalRowsDescription: "每次查询后自动执行 COUNT(*) 显示匹配的总行数。默认关闭以保证大查询速度 —— 可在结果栏按需手动统计。",
|
||||
infiniteScrollDescription: "滚动到表格底部时自动加载下一页数据,无需手动翻页。",
|
||||
infiniteScrollMaxRows: "无限滚动最大行数",
|
||||
infiniteScrollMaxRowsDescription: "无限滚动模式下最多加载的行数(1000–50000)。",
|
||||
infiniteScrollDescription: "滚动到底部时自动加载下一批数据,无需手动翻页或加载。",
|
||||
infiniteScrollMaxRows: "无限滚动最大条目数",
|
||||
infiniteScrollMaxRowsDescription: "无限滚动模式下自动加载的最大条目数(1000–50000)。",
|
||||
regexMaxMatchCount: "全部选中最大数量",
|
||||
regexMaxMatchCountDescription: "限制“全部选中”一次创建的选区数量;搜索匹配仍完整统计(100–10000)。",
|
||||
tableColumnTemplateFields: "新建表预设字段",
|
||||
|
|
|
|||
|
|
@ -4377,9 +4377,9 @@ export default withEnglishFallback({
|
|||
infiniteScroll: "無限滾動載入",
|
||||
autoCalculateTotalRows: "自動統計總筆數",
|
||||
autoCalculateTotalRowsDescription: "每次查詢後自動執行 COUNT(*) 顯示符合的總筆數。預設關閉以確保大型查詢速度 —— 可在結果列按需手動統計。",
|
||||
infiniteScrollDescription: "滾動到表格底部時自動載入下一頁資料,無需手動翻頁。",
|
||||
infiniteScrollMaxRows: "無限滾動最大筆數",
|
||||
infiniteScrollMaxRowsDescription: "無限滾動模式下最多載入的筆數(1000–50000)。",
|
||||
infiniteScrollDescription: "滾動到底部時自動載入下一批資料,無需手動翻頁或載入。",
|
||||
infiniteScrollMaxRows: "無限滾動最大項目數",
|
||||
infiniteScrollMaxRowsDescription: "無限滾動模式下自動載入的最大項目數(1000–50000)。",
|
||||
regexMaxMatchCount: "全部選取最大數量",
|
||||
regexMaxMatchCountDescription: "限制「全部選取」一次建立的選取範圍數量;搜尋符合結果仍會完整統計(100–10000)。",
|
||||
tableColumnTemplateFields: "新建表預設欄位",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll";
|
||||
|
||||
const baseState = {
|
||||
enabled: true,
|
||||
hasMore: true,
|
||||
busy: false,
|
||||
loadedKeys: 1000,
|
||||
maxKeys: 5000,
|
||||
scrollTop: 800,
|
||||
clientHeight: 200,
|
||||
scrollHeight: 1050,
|
||||
};
|
||||
|
||||
describe("shouldLoadMoreRedisKeys", () => {
|
||||
it("loads the next scan page near the bottom", () => {
|
||||
expect(shouldLoadMoreRedisKeys(baseState)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not load while disabled, busy, or exhausted", () => {
|
||||
expect(shouldLoadMoreRedisKeys({ ...baseState, enabled: false })).toBe(false);
|
||||
expect(shouldLoadMoreRedisKeys({ ...baseState, busy: true })).toBe(false);
|
||||
expect(shouldLoadMoreRedisKeys({ ...baseState, hasMore: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("stops automatic loading at the configured maximum", () => {
|
||||
expect(shouldLoadMoreRedisKeys({ ...baseState, loadedKeys: 5000 })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not load before reaching the bottom threshold", () => {
|
||||
expect(shouldLoadMoreRedisKeys({ ...baseState, scrollTop: 700 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export interface RedisKeyInfiniteScrollState {
|
||||
enabled: boolean;
|
||||
hasMore: boolean;
|
||||
busy: boolean;
|
||||
loadedKeys: number;
|
||||
maxKeys: number;
|
||||
scrollTop: number;
|
||||
clientHeight: number;
|
||||
scrollHeight: number;
|
||||
}
|
||||
|
||||
export function shouldLoadMoreRedisKeys(state: RedisKeyInfiniteScrollState, threshold = 100): boolean {
|
||||
if (!state.enabled || !state.hasMore || state.busy) return false;
|
||||
if (state.loadedKeys >= state.maxKeys || state.clientHeight <= 0) return false;
|
||||
return state.scrollTop + state.clientHeight >= state.scrollHeight - Math.max(0, threshold);
|
||||
}
|
||||
Loading…
Reference in New Issue