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