fix(editor): improve result navigation and scrolling performance
This commit is contained in:
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 });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1492,6 +1492,9 @@ export default withEnglishFallback({
|
||||
runN: "実行 {n}",
|
||||
resultRuns: "実行履歴",
|
||||
resultSets: "結果セット",
|
||||
allResults: "すべての結果({count})",
|
||||
searchResults: "番号、名前、SQL で検索",
|
||||
noMatchingResults: "一致する結果はありません",
|
||||
removeRun: "実行 {n} を削除",
|
||||
pinResultRun: "結果タブを固定",
|
||||
unpinResultRun: "結果タブの固定を解除",
|
||||
|
||||
@@ -1379,6 +1379,9 @@ export default withEnglishFallback({
|
||||
runN: "실행 {n}",
|
||||
resultRuns: "결과 실행",
|
||||
resultSets: "결과 집합",
|
||||
allResults: "전체 결과 ({count})",
|
||||
searchResults: "번호, 이름 또는 SQL 검색",
|
||||
noMatchingResults: "일치하는 결과가 없습니다",
|
||||
removeRun: "실행 {n} 제거",
|
||||
pinResultRun: "결과 탭 고정",
|
||||
unpinResultRun: "결과 탭 고정 해제",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1422,6 +1422,9 @@ export default withEnglishFallback({
|
||||
runN: "执行 {n}",
|
||||
resultRuns: "执行结果",
|
||||
resultSets: "语句结果",
|
||||
allResults: "全部结果({count})",
|
||||
searchResults: "搜索编号、名称或 SQL",
|
||||
noMatchingResults: "没有匹配的结果",
|
||||
removeRun: "删除执行 {n}",
|
||||
pinResultRun: "固定结果标签",
|
||||
unpinResultRun: "取消固定结果标签",
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user