diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 8100ee59a0..a99d96154f 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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(); const view = shallowRef(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 | 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({ diff --git a/apps/desktop/src/components/layout/__tests__/ResultSetNavigator.spec.ts b/apps/desktop/src/components/layout/__tests__/ResultSetNavigator.spec.ts new file mode 100644 index 0000000000..a9dd4870ad --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/ResultSetNavigator.spec.ts @@ -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(".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('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 }); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 1503789e59..7b573ad3f3 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d0cade479c..52d7262318 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5544dcb06a..d54023c9d9 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3d72d68f8f..7e3ff3d181 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 01f83f9467..15d572e430 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1492,6 +1492,9 @@ export default withEnglishFallback({ runN: "実行 {n}", resultRuns: "実行履歴", resultSets: "結果セット", + allResults: "すべての結果({count})", + searchResults: "番号、名前、SQL で検索", + noMatchingResults: "一致する結果はありません", removeRun: "実行 {n} を削除", pinResultRun: "結果タブを固定", unpinResultRun: "結果タブの固定を解除", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 36b5efcbe0..6017df692b 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1379,6 +1379,9 @@ export default withEnglishFallback({ runN: "실행 {n}", resultRuns: "결과 실행", resultSets: "결과 집합", + allResults: "전체 결과 ({count})", + searchResults: "번호, 이름 또는 SQL 검색", + noMatchingResults: "일치하는 결과가 없습니다", removeRun: "실행 {n} 제거", pinResultRun: "결과 탭 고정", unpinResultRun: "결과 탭 고정 해제", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 66e11a1d7a..61bce56b3d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index ab288e3619..b43f8744ca 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9a7bf32e9d..df22968e45 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1422,6 +1422,9 @@ export default withEnglishFallback({ runN: "执行 {n}", resultRuns: "执行结果", resultSets: "语句结果", + allResults: "全部结果({count})", + searchResults: "搜索编号、名称或 SQL", + noMatchingResults: "没有匹配的结果", removeRun: "删除执行 {n}", pinResultRun: "固定结果标签", unpinResultRun: "取消固定结果标签", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index cc485ffdf4..859c2904ba 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1474,6 +1474,9 @@ export default withEnglishFallback({ runN: "執行 {n}", resultRuns: "執行結果", resultSets: "語句結果", + allResults: "全部結果({count})", + searchResults: "搜尋編號、名稱或 SQL", + noMatchingResults: "沒有符合的結果", removeRun: "移除執行 {n}", pinResultRun: "釘選結果分頁", unpinResultRun: "取消釘選結果分頁", diff --git a/apps/desktop/src/lib/__tests__/editor/deferredEditorTask.spec.ts b/apps/desktop/src/lib/__tests__/editor/deferredEditorTask.spec.ts new file mode 100644 index 0000000000..997bd97c2c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/deferredEditorTask.spec.ts @@ -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(); + }); +}); diff --git a/apps/desktop/src/lib/editor/deferredEditorTask.ts b/apps/desktop/src/lib/editor/deferredEditorTask.ts new file mode 100644 index 0000000000..958af62cdf --- /dev/null +++ b/apps/desktop/src/lib/editor/deferredEditorTask.ts @@ -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 | 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(); + }, + }; +} diff --git a/packages/app-tests/queryResultToolbar.test.ts b/packages/app-tests/queryResultToolbar.test.ts index effa331f76..9959a485cf 100644 --- a/packages/app-tests/queryResultToolbar.test.ts +++ b/packages/app-tests/queryResultToolbar.test.ts @@ -4,6 +4,7 @@ import { test } from "vitest"; import { compileScript, compileTemplate, parse } from "vue/compiler-sfc"; const contentAreaPath = "apps/desktop/src/components/layout/ContentArea.vue"; +const resultSetNavigatorPath = "apps/desktop/src/components/layout/ResultSetNavigator.vue"; const appPath = "apps/desktop/src/App.vue"; const dataGridPath = "apps/desktop/src/components/grid/DataGrid.vue"; const dataGridExportMenuPath = "apps/desktop/src/components/grid/DataGridExportMenu.vue"; @@ -66,7 +67,7 @@ test("ContentArea exposes retained result runs as switchable tabs or a compact l const contentArea = source(contentAreaPath); const runScrollerStart = contentArea.indexOf('ref="resultTabsScrollerRef"'); const listSelectorStart = contentArea.indexOf('
resultRuns\.value\.length > 0 && resultRunDisplayMode\.value === "tabs"\)/); assert.match(contentArea, /showResultRunSelector = computed\(\(\) => resultRuns\.value\.length > 0 && resultRunDisplayMode\.value === "list"\)/); @@ -85,6 +86,7 @@ test("ContentArea exposes retained result runs as switchable tabs or a compact l assert.ok(runScrollerStart >= 0); assert.ok(listSelectorStart > runScrollerStart); assert.ok(fixedResultSetStart > listSelectorStart); + assert.match(source(resultSetNavigatorPath), /data-result-set-tabs-region/); assert.doesNotMatch(contentArea.slice(runScrollerStart, listSelectorStart), /visibleResultItems/); assert.match(contentArea, /resultAutoSave \? 'bg-primary\/10 text-primary[\s\S]*: 'text-muted-foreground hover:bg-accent hover:text-foreground'/); assert.doesNotMatch(contentArea, /queryResultAutoRefresh|QUERY_RESULT_AUTO_REFRESH|nextResultToolbarLayout/);