feat(redis): improve key browser toolbar and infinite scroll loading

This commit is contained in:
zipg 2026-08-09 14:14:45 +08:00 committed by GitHub
parent b1fcb23625
commit 328a2aa107
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 254 additions and 40 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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));

View File

@ -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>

View File

@ -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 (100050000).",
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 (100050000).",
regexMaxMatchCount: "Maximum select-all matches",
regexMaxMatchCountDescription: "Limits the number of selections created by Select All Matches; search counting remains complete (10010000).",
tableColumnTemplateFields: "New Table Preset Fields",

View File

@ -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 (100050000).",
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 (100050000).",
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 (10010000).",
tableColumnTemplateFields: "Campos predefinidos para tablas nuevas",

View File

@ -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 (100050000).",
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 (100050000).",
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 (10010000).",
tableColumnTemplateFields: "Campi predefiniti per nuove tabelle",

View File

@ -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: "エクスポート行数を制限",

View File

@ -4685,9 +4685,9 @@ export default withEnglishFallback({
infiniteScroll: "무한 스크롤 로딩",
autoCalculateTotalRows: "전체 행 수 자동 집계",
autoCalculateTotalRowsDescription: "각 쿼리 후 자동으로 COUNT(*)를 실행하여 일치하는 전체 행 수를 표시합니다. 대규모 쿼리를 빠르게 유지하기 위해 기본적으로 꺼져 있으며, 결과 푸터에서 요청 시 집계할 수 있습니다.",
infiniteScrollDescription: "테이블 하단으로 스크롤할 때 다음 데이터 페이지를 자동으로 불러옵니다.",
infiniteScrollMaxRows: "무한 스크롤 최대 ",
infiniteScrollMaxRowsDescription: "무한 스크롤 모드에서 로드할 최대 행 수 (100050000).",
infiniteScrollDescription: "목록 하단으로 스크롤할 때 다음 데이터 묶음을 자동으로 불러옵니다.",
infiniteScrollMaxRows: "무한 스크롤 최대 항목 수",
infiniteScrollMaxRowsDescription: "무한 스크롤 모드에서 자동으로 불러올 항목의 최대 수 (100050000).",
tableColumnTemplateFields: "새 테이블 미리 정의된 필드",
tableColumnTemplateFieldsDescription: "데이터베이스 유형을 선택한 다음 새 테이블을 만들 때 사용할 미리 정의된 필드 타입을 구성하세요.",
tableColumnTemplateAdd: "필드 추가",

View File

@ -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 (100050000).",
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 (100050000).",
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 (10010000).",
tableColumnTemplateFields: "Campos predefinidos para novas tabelas",

View File

@ -5166,9 +5166,9 @@ export default withEnglishFallback({
infiniteScroll: "无限滚动加载",
autoCalculateTotalRows: "自动统计总行数",
autoCalculateTotalRowsDescription: "每次查询后自动执行 COUNT(*) 显示匹配的总行数。默认关闭以保证大查询速度 —— 可在结果栏按需手动统计。",
infiniteScrollDescription: "滚动到表格底部时自动加载下一页数据,无需手动翻页。",
infiniteScrollMaxRows: "无限滚动最大数",
infiniteScrollMaxRowsDescription: "无限滚动模式下最多加载的行100050000。",
infiniteScrollDescription: "滚动到底部时自动加载下一批数据,无需手动翻页或加载。",
infiniteScrollMaxRows: "无限滚动最大条目数",
infiniteScrollMaxRowsDescription: "无限滚动模式下自动加载的最大条目100050000。",
regexMaxMatchCount: "全部选中最大数量",
regexMaxMatchCountDescription: "限制“全部选中”一次创建的选区数量搜索匹配仍完整统计10010000。",
tableColumnTemplateFields: "新建表预设字段",

View File

@ -4377,9 +4377,9 @@ export default withEnglishFallback({
infiniteScroll: "無限滾動載入",
autoCalculateTotalRows: "自動統計總筆數",
autoCalculateTotalRowsDescription: "每次查詢後自動執行 COUNT(*) 顯示符合的總筆數。預設關閉以確保大型查詢速度 —— 可在結果列按需手動統計。",
infiniteScrollDescription: "滾動到表格底部時自動載入下一頁資料,無需手動翻頁。",
infiniteScrollMaxRows: "無限滾動最大數",
infiniteScrollMaxRowsDescription: "無限滾動模式下最多載入的筆100050000。",
infiniteScrollDescription: "滾動到底部時自動載入下一批資料,無需手動翻頁或載入。",
infiniteScrollMaxRows: "無限滾動最大項目數",
infiniteScrollMaxRowsDescription: "無限滾動模式下自動載入的最大項目100050000。",
regexMaxMatchCount: "全部選取最大數量",
regexMaxMatchCountDescription: "限制「全部選取」一次建立的選取範圍數量搜尋符合結果仍會完整統計10010000。",
tableColumnTemplateFields: "新建表預設欄位",

View File

@ -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);
});
});

View File

@ -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);
}