fix(editor): improve result navigation and scrolling performance

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-11 10:51:41 +08:00
1 parent 14298b31a8
commit 15d451d43a
17 files changed
+392 -36

No files matched your search

@@ -18,6 +18,7 @@ import { completionMatchRanges } from "@/lib/common/completionMatch";
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
@@ -247,7 +248,7 @@ const emit = defineEmits<{
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
closeColumnPanel: [];
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }];
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }, tabId?: string];
selectionStateChange: [selection: { anchor: number; head: number }];
editorStateFlushed: [];
sendSelectionToAi: [sql: string];
@@ -257,7 +258,10 @@ const editorRef = ref<HTMLDivElement>();
const view = shallowRef<EditorViewType | null>(null);
const contextMenuOpen = ref(false);
let contextMenuPointerCleanup: (() => void) | null = null;
let viewportEmitFrame: number | null = null;
let viewportOwnerTabId = props.tabId;
const viewportEmitTask = createDeferredEditorTask(() => {
if (latestViewport) emitEditorViewport(latestViewport);
}, 150);
let viewportRestoreFrame: number | null = null;
let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
let lastEmittedViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport;
@@ -6058,7 +6062,9 @@ onMounted(async () => {
ViewPlugin.fromClass(
class {
decorations: import("@codemirror/view").DecorationSet;
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private refreshTask = createDeferredEditorTask(() => {
if (this.currentView.dom.isConnected) this.currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
private cachedDoc: import("@codemirror/state").Text | null = null;
private cachedSql = "";
private cachedDialectId = "";
@@ -6069,7 +6075,7 @@ onMounted(async () => {
spans: Array<{ start: number; end: number }>;
}> = [];
constructor(currentView: import("@codemirror/view").EditorView) {
constructor(private currentView: import("@codemirror/view").EditorView) {
this.decorations = this.buildDecorations(currentView);
}
@@ -6080,24 +6086,23 @@ onMounted(async () => {
this.scheduleRefresh(update.view);
return;
}
if (refreshRequested || update.viewportChanged) {
if (refreshRequested) {
this.cancelRefresh();
this.decorations = this.buildDecorations(update.view);
} else if (update.viewportChanged) {
// Keep existing decorations while scrolling. Parse only after the
// viewport settles instead of blocking CodeMirror's layout update.
this.scheduleRefresh(update.view);
}
}
scheduleRefresh(currentView: import("@codemirror/view").EditorView) {
if (this.refreshTimer !== null) return;
this.refreshTimer = setTimeout(() => {
this.refreshTimer = null;
if (currentView.dom.isConnected) currentView.dispatch({ effects: refreshSqlSemanticHighlightEffect.of(null) });
}, SQL_SEMANTIC_HIGHLIGHT_DEBOUNCE_MS);
this.currentView = currentView;
this.refreshTask.schedule();
}
cancelRefresh() {
if (this.refreshTimer === null) return;
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
this.refreshTask.cancel();
}
destroy() {
@@ -6841,6 +6846,12 @@ function swapEditorDocument(doc: string) {
function activateTabDocument(prevTabId: string | undefined, tabId: string | undefined, doc: string) {
const currentView = view.value;
if (!currentView) return;
// Flush the outgoing document before props and restored scroll positions
// become the new tab's state. The event carries its original owner.
flushEditorViewport();
viewportOwnerTabId = tabId;
latestViewport = props.initialViewport ?? { scrollTop: 0, scrollLeft: 0 };
lastEmittedViewport = undefined;
clearScheduledPreviewContextRefresh();
if (prevTabId !== undefined) {
tabStateCache.set(prevTabId, currentView.state);
@@ -7168,10 +7179,7 @@ onDeactivated(pauseQueryEditorBackgroundWork);
onBeforeUnmount(() => {
pauseQueryEditorBackgroundWork();
if (viewportEmitFrame !== null) {
cancelAnimationFrame(viewportEmitFrame);
viewportEmitFrame = null;
}
viewportEmitTask.cancel();
if (viewportRestoreFrame !== null) {
cancelAnimationFrame(viewportRestoreFrame);
viewportRestoreFrame = null;
@@ -7244,25 +7252,19 @@ function restoreEditorFocus() {
function emitEditorViewport(viewport: { scrollTop: number; scrollLeft: number }) {
if (sameEditorViewport(lastEmittedViewport, viewport)) return;
lastEmittedViewport = { ...viewport };
emit("viewportChange", viewport);
emit("viewportChange", viewport, viewportOwnerTabId);
}
function scheduleEditorViewportEmit() {
if (!view.value || !editorIsActive) return;
latestViewport = readEditorViewport(view.value);
scheduleSemanticDiagnostics(700, { preserveOutsideRanges: true });
if (viewportEmitFrame !== null) return;
viewportEmitFrame = requestAnimationFrame(() => {
viewportEmitFrame = null;
if (latestViewport) emitEditorViewport(latestViewport);
});
viewportEmitTask.schedule();
}
function flushEditorViewport() {
if (viewportEmitFrame !== null) {
cancelAnimationFrame(viewportEmitFrame);
viewportEmitFrame = null;
}
if (view.value) latestViewport = readEditorViewport(view.value);
viewportEmitTask.flush();
if (latestViewport) emitEditorViewport(latestViewport);
}
@@ -7405,6 +7407,33 @@ defineExpose({
</template>
<style scoped>
[data-query-editor-root] > :deep(.cm-editor) {
/* The host supplies both dimensions. Keep viewport DOM changes from
invalidating intrinsic sizes throughout the surrounding flex layout;
WebKit otherwise spends a frame recomputing it on each viewport change.
Leave paint uncontained so editor overlays retain their overflow. */
contain: size layout style;
}
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar) {
width: 5px;
}
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar-track) {
background: rgba(127, 127, 127, 0.1);
}
[data-query-editor-root] :deep(.cm-scroller::-webkit-scrollbar-thumb) {
background: rgba(127, 127, 127, 0.7);
border-radius: 999px;
}
@supports not selector(::-webkit-scrollbar) {
[data-query-editor-root] :deep(.cm-scroller) {
scrollbar-width: thin;
}
}
.query-editor--table-navigation-hover :deep(.cm-content),
.query-editor--table-navigation-hover :deep(.cm-line) {
cursor: pointer;
@@ -61,6 +61,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
@@ -1312,7 +1313,7 @@ defineExpose({
@send-selection-to-ai="emit('sendSelectionToAi', activeTab.id, $event)"
@cursor-change="emit('editorCursorChange', activeTab.id, $event)"
@preview-changes-available="emit('previewChangesAvailable', activeTab.id, $event)"
@viewport-change="emit('editorViewportChange', activeTab.id, $event)"
@viewport-change="(viewport, tabId) => emit('editorViewportChange', tabId ?? activeTab.id, viewport)"
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
@editor-state-flushed="emit('editorStateFlushed', activeTab.id)"
@format-error="emit('formatError', activeTab.id)"
@@ -1444,13 +1445,7 @@ defineExpose({
</div>
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
<div v-if="visibleResultItems.length > 0" data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 items-center gap-1" :class="resultRuns.length > 0 ? 'shrink-0' : 'flex-1 overflow-x-auto'">
<LightTooltip v-for="item in visibleResultItems" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :disabled="!item.labelTruncated && !(!item.label && item.title)" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'" class="h-6 min-w-0 max-w-48 shrink-0 px-2 text-xs" :aria-label="item.label || t('tabs.resultN', { n: item.n })" @click="selectResultItem(item)">
<span class="block min-w-0 max-w-44 whitespace-nowrap">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
</div>
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Check, ChevronDown } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { useTabScroll } from "@/composables/useTabScroll";
import type { tabularResultItems } from "@/lib/tabs/tabPresentation";
type ResultItem = ReturnType<typeof tabularResultItems>[number];
const props = defineProps<{ items: ResultItem[]; activeIndex: number; active: boolean }>();
const emit = defineEmits<{ select: [item: ResultItem] }>();
const { t } = useI18n();
const scroller = ref<HTMLElement | null>(null);
const list = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
const open = ref(false);
const search = ref("");
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(scroller);
const thumbStyle = computed(() => ({ left: `${scrollThumbLeftPercent.value}%`, width: `${scrollThumbWidthPercent.value}%` }));
const filteredItems = computed(() => {
const query = search.value.trim().toLocaleLowerCase();
if (!query) return props.items;
// A number is an exact result ordinal, not a substring of every SQL statement.
if (/^\d+$/.test(query)) return props.items.filter((item) => item.n === Number(query));
return props.items.filter((item) => [item.label, item.title, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
});
function revealActive() {
const container = scroller.value;
const button = container?.querySelector<HTMLElement>('[data-active="true"]');
if (container && button) {
// Scroll only the strip; scrollIntoView can also move the surrounding result pane.
const viewport = container.getBoundingClientRect();
const bounds = button.getBoundingClientRect();
if (bounds.left < viewport.left) container.scrollLeft -= viewport.left - bounds.left;
else if (bounds.right > viewport.right) container.scrollLeft += bounds.right - viewport.right;
}
updateScrollButtons();
}
watch(
() => [props.items, props.activeIndex, props.active],
() => nextTick(revealActive),
{ flush: "post", immediate: true },
);
watch(open, () => {
search.value = "";
});
function select(item: ResultItem) {
emit("select", item);
open.value = false;
}
function focusListItem(index: number) {
const buttons = list.value?.querySelectorAll<HTMLButtonElement>("button");
if (!buttons?.length) return;
buttons[Math.max(0, Math.min(index, buttons.length - 1))]?.focus();
}
function onSearchKeydown(event: KeyboardEvent) {
if (event.isComposing) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
focusListItem(event.key === "ArrowDown" ? 0 : filteredItems.value.length - 1);
} else if (event.key === "Enter" && filteredItems.value[0]) {
event.preventDefault();
select(filteredItems.value[0]);
}
}
function onListKeydown(event: KeyboardEvent, index: number) {
if (event.key === "ArrowUp" && index === 0) {
event.preventDefault();
searchInput.value?.focus();
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
focusListItem(event.key === "Home" ? 0 : event.key === "End" ? filteredItems.value.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1));
}
}
</script>
<template>
<div data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden">
<div class="relative h-full min-w-0 flex-1">
<div ref="scroller" class="result-set-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" @scroll="updateScrollButtons" @wheel="onTabsWheel">
<LightTooltip v-for="item in items" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="active && activeIndex === item.index ? 'default' : 'ghost'" class="h-6 max-w-48 shrink-0 px-2 text-xs" :data-active="active && activeIndex === item.index ? 'true' : undefined" :aria-pressed="active && activeIndex === item.index" @click="select(item)">
<span class="truncate">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
</div>
<div v-if="hasTabOverflow" class="result-set-scrollbar" :class="{ dragging: isScrollbarDragging }" @pointerdown="startScrollbarDrag">
<div class="result-set-scrollbar-thumb" :style="thumbStyle" />
</div>
</div>
<Popover v-if="items.length > 1" v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs" :title="t('tabs.allResults', { count: items.length })">
{{ t("tabs.allResults", { count: items.length }) }}
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-96 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-1 p-1" @open-auto-focus.prevent="searchInput?.focus()">
<input
ref="searchInput"
v-model="search"
type="search"
class="m-1 shrink-0 rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
:placeholder="t('tabs.searchResults')"
:aria-label="t('tabs.searchResults')"
@keydown="onSearchKeydown"
/>
<div ref="list" class="min-h-0 max-h-72 overflow-y-auto overscroll-contain" :aria-label="t('tabs.resultSets')">
<button
v-for="(item, index) in filteredItems"
:key="item.index"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
:aria-pressed="active && activeIndex === item.index"
@click="select(item)"
@keydown="onListKeydown($event, index)"
>
<Check class="h-3.5 w-3.5 shrink-0" :class="{ invisible: !active || activeIndex !== item.index }" />
<span class="min-w-0 flex-1">
<span class="block truncate font-medium"
>{{ t("tabs.resultN", { n: item.n }) }}<template v-if="item.label"> · {{ item.label }}</template></span
>
<span v-if="item.title" class="block truncate text-muted-foreground">{{ item.title }}</span>
</span>
</button>
<p v-if="!filteredItems.length" role="status" class="p-4 text-center text-xs text-muted-foreground">{{ t("tabs.noMatchingResults") }}</p>
</div>
</PopoverContent>
</Popover>
</div>
</template>
<style scoped>
.result-set-scroll {
scrollbar-width: none;
}
.result-set-scroll::-webkit-scrollbar {
display: none;
}
.result-set-scrollbar {
position: absolute;
inset-inline: 0;
bottom: 0;
height: 8px;
cursor: pointer;
touch-action: none;
}
.result-set-scrollbar-thumb {
position: absolute;
top: 3px;
height: 3px;
border-radius: 999px;
background: var(--foreground);
opacity: 0.38;
}
.result-set-scrollbar:hover .result-set-scrollbar-thumb,
.dragging .result-set-scrollbar-thumb {
top: 1px;
height: 6px;
opacity: 0.58;
}
</style>
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import ResultSetNavigator from "../ResultSetNavigator.vue";
import { tabularResultItems } from "@/lib/tabs/tabPresentation";
import type { QueryResult } from "@/types/database";
let app: App | undefined;
afterEach(() => {
app?.unmount();
document.body.replaceChildren();
});
async function mountNavigator() {
// Include a non-tabular result so ordinal and storage index are different.
const results = [{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[];
const select = vi.fn();
const container = document.createElement("div");
document.body.append(container);
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex: 1, active: true, onSelect: select });
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { tabs: { resultN: "Result {n}", resultSets: "Result sets", allResults: "All results ({count})", searchResults: "Search", noMatchingResults: "No matches" } } } }));
app.mount(container);
await nextTick();
return { container, select };
}
describe("large result-set navigation", () => {
it("scrolls the result-set strip with a vertical wheel and consumes boundary gestures", async () => {
const { container } = await mountNavigator();
const strip = container.querySelector<HTMLElement>(".result-set-scroll")!;
Object.defineProperties(strip, { clientWidth: { value: 400 }, scrollWidth: { value: 8000 } });
const wheel = new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true });
strip.dispatchEvent(wheel);
expect(strip.scrollLeft).toBe(200);
expect(wheel.defaultPrevented).toBe(true);
strip.scrollLeft = 7600;
const boundary = new WheelEvent("wheel", { deltaY: 200, bubbles: true, cancelable: true });
strip.dispatchEvent(boundary);
expect(strip.scrollLeft).toBe(7600);
expect(boundary.defaultPrevented).toBe(true);
});
it("finds an exact ordinal among 100 results and selects its original storage index", async () => {
const { container, select } = await mountNavigator();
const trigger = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("All results"))!;
trigger.click();
await nextTick();
await nextTick();
const input = document.querySelector<HTMLInputElement>('input[type="search"]')!;
expect(input).toBeTruthy();
input.value = "8";
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const popup = document.querySelector('[data-slot="popover-content"]')!;
expect(popup.querySelectorAll("button")).toHaveLength(1);
expect(popup.textContent).toContain("Result 8");
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(select).toHaveBeenCalledOnce();
expect(select.mock.calls[0]![0]).toMatchObject({ n: 8, index: 8 });
});
});
+3
View File
@@ -1497,6 +1497,9 @@ export default withEnglishFallback({
runN: "İcra {n}",
resultRuns: "Nəticə icraları",
resultSets: "Nəticə dəstləri",
allResults: "Bütün nəticələr ({count})",
searchResults: "Nömrə, ad və ya SQL üzrə axtar",
noMatchingResults: "Uyğun nəticə yoxdur",
removeRun: "{n} nömrəli icranı çıxar",
pinResultRun: "Nəticə vərəqini sabitlə",
unpinResultRun: "Nəticə vərəqinin sabitlənməsini ləğv et",
+3
View File
@@ -1498,6 +1498,9 @@ export default {
runN: "Run {n}",
resultRuns: "Result runs",
resultSets: "Result sets",
allResults: "All results ({count})",
searchResults: "Search number, name or SQL",
noMatchingResults: "No matching results",
removeRun: "Remove run {n}",
pinResultRun: "Pin result tab",
unpinResultRun: "Unpin result tab",
+3
View File
@@ -1475,6 +1475,9 @@ export default withEnglishFallback({
runN: "Ejecutar {n}",
resultRuns: "Ejecuciones de resultado",
resultSets: "Conjuntos de resultados",
allResults: "Todos los resultados ({count})",
searchResults: "Buscar por número, nombre o SQL",
noMatchingResults: "No hay resultados coincidentes",
removeRun: "Eliminar ejecución {n}",
pinResultRun: "Fijar pestaña de resultado",
unpinResultRun: "Desfijar pestaña de resultado",
+3
View File
@@ -1473,6 +1473,9 @@ export default withEnglishFallback({
runN: "Esegui {n}",
resultRuns: "Esecuzioni risultati",
resultSets: "Set di risultati",
allResults: "Tutti i risultati ({count})",
searchResults: "Cerca per numero, nome o SQL",
noMatchingResults: "Nessun risultato corrispondente",
removeRun: "Rimuovi esecuzione {n}",
pinResultRun: "Fissa scheda risultato",
unpinResultRun: "Sblocca scheda risultato",
+3
View File
@@ -1492,6 +1492,9 @@ export default withEnglishFallback({
runN: "実行 {n}",
resultRuns: "実行履歴",
resultSets: "結果セット",
allResults: "すべての結果({count})",
searchResults: "番号、名前、SQL で検索",
noMatchingResults: "一致する結果はありません",
removeRun: "実行 {n} を削除",
pinResultRun: "結果タブを固定",
unpinResultRun: "結果タブの固定を解除",
+3
View File
@@ -1379,6 +1379,9 @@ export default withEnglishFallback({
runN: "실행 {n}",
resultRuns: "결과 실행",
resultSets: "결과 집합",
allResults: "전체 결과 ({count})",
searchResults: "번호, 이름 또는 SQL 검색",
noMatchingResults: "일치하는 결과가 없습니다",
removeRun: "실행 {n} 제거",
pinResultRun: "결과 탭 고정",
unpinResultRun: "결과 탭 고정 해제",
+3
View File
@@ -1475,6 +1475,9 @@ export default withEnglishFallback({
runN: "Executar {n}",
resultRuns: "Execuções de resultado",
resultSets: "Conjuntos de resultados",
allResults: "Todos os resultados ({count})",
searchResults: "Buscar por número, nome ou SQL",
noMatchingResults: "Nenhum resultado correspondente",
removeRun: "Remover execução {n}",
pinResultRun: "Fixar aba de resultado",
unpinResultRun: "Desafixar aba de resultado",
+3
View File
@@ -1498,6 +1498,9 @@ export default withEnglishFallback({
runN: "Çalıştırma {n}",
resultRuns: "Sonuç çalıştırmaları",
resultSets: "Sonuç kümeleri",
allResults: "Tüm sonuçlar ({count})",
searchResults: "Numara, ad veya SQL ile ara",
noMatchingResults: "Eşleşen sonuç yok",
removeRun: "{n} numaralı çalıştırmayı kaldır",
pinResultRun: "Sonuç sekmesini sabitle",
unpinResultRun: "Sonuç sekmesinin sabitlemesini kaldır",
+3
View File
@@ -1422,6 +1422,9 @@ export default withEnglishFallback({
runN: "执行 {n}",
resultRuns: "执行结果",
resultSets: "语句结果",
allResults: "全部结果({count})",
searchResults: "搜索编号、名称或 SQL",
noMatchingResults: "没有匹配的结果",
removeRun: "删除执行 {n}",
pinResultRun: "固定结果标签",
unpinResultRun: "取消固定结果标签",
+3
View File
@@ -1474,6 +1474,9 @@ export default withEnglishFallback({
runN: "執行 {n}",
resultRuns: "執行結果",
resultSets: "語句結果",
allResults: "全部結果({count})",
searchResults: "搜尋編號、名稱或 SQL",
noMatchingResults: "沒有符合的結果",
removeRun: "移除執行 {n}",
pinResultRun: "釘選結果分頁",
unpinResultRun: "取消釘選結果分頁",
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
afterEach(() => vi.useRealTimers());
describe("deferred editor work", () => {
it("does no work during sustained scrolling and processes the final viewport once", () => {
vi.useFakeTimers();
let viewport = 0;
const publish = vi.fn(() => viewport);
const task = createDeferredEditorTask(publish, 150);
for (let frame = 0; frame < 100; frame++) {
viewport = frame * 20;
task.schedule();
vi.advanceTimersByTime(16);
}
expect(publish).not.toHaveBeenCalled();
vi.advanceTimersByTime(150);
expect(publish).toHaveBeenCalledOnce();
expect(publish).toHaveReturnedWith(1980);
});
it("flushes pending work before an owner switch without a later duplicate", () => {
vi.useFakeTimers();
let owner = "old-tab";
const publish = vi.fn(() => owner);
const task = createDeferredEditorTask(publish, 150);
task.schedule();
task.flush();
owner = "new-tab";
vi.runAllTimers();
expect(publish).toHaveBeenCalledOnce();
expect(publish).toHaveReturnedWith("old-tab");
});
it("cancels semantic refreshes when the editor plugin is destroyed", () => {
vi.useFakeTimers();
const refresh = vi.fn();
const task = createDeferredEditorTask(refresh, 100);
task.schedule();
task.cancel();
vi.runAllTimers();
expect(refresh).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,23 @@
/** Keep nonvisual work off continuous typing/scrolling, with an explicit lifecycle flush. */
export function createDeferredEditorTask(run: () => void, delay: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
function cancel() {
if (timer !== null) clearTimeout(timer);
timer = null;
}
return {
schedule() {
cancel();
timer = setTimeout(() => {
timer = null;
run();
}, delay);
},
cancel,
flush() {
if (timer === null) return;
cancel();
run();
},
};
}