From 8eba12ea2f3ebbfcc30d13311fc9d4e1f5b1a458 Mon Sep 17 00:00:00 2001 From: dengjie zhang <2356288676@qq.com> Date: Fri, 18 Sep 2026 01:48:27 +0800 Subject: [PATCH] perf(layout): track panel resizing at a stepped cadence --- apps/desktop/src/App.vue | 2 + .../src/components/editor/QueryEditor.vue | 86 +++++++ apps/desktop/src/components/grid/DataGrid.vue | 111 ++++++--- .../src/components/layout/ContentArea.vue | 7 +- .../components/layout/SqlEditorWorkspace.vue | 27 ++- .../SqlEditorWorkspace.resize.spec.ts | 216 ++++++++++++++++++ .../components/layout/sqlEditorWorkspace.css | 7 + .../desktop/src/composables/usePanelResize.ts | 21 +- apps/desktop/src/composables/useTabScroll.ts | 5 + .../src/composables/useToolbarOverflow.ts | 5 + .../src/lib/app/__tests__/uiTuning.spec.ts | 69 ++++++ apps/desktop/src/lib/app/panelResizeState.ts | 56 +++++ apps/desktop/src/lib/app/uiTuning.ts | 57 +++++ apps/desktop/src/styles/globals.css | 39 ++++ packages/app-tests/queryResultToolbar.test.ts | 5 +- .../app-tests/uiTuningPermissions.test.ts | 17 ++ src-tauri/capabilities/default.json | 9 +- 17 files changed, 705 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/components/layout/__tests__/SqlEditorWorkspace.resize.spec.ts create mode 100644 apps/desktop/src/lib/app/__tests__/uiTuning.spec.ts create mode 100644 apps/desktop/src/lib/app/panelResizeState.ts create mode 100644 apps/desktop/src/lib/app/uiTuning.ts create mode 100644 packages/app-tests/uiTuningPermissions.test.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index d58a063f7e..87245ca2a2 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -30,6 +30,7 @@ import { useExportTracker } from "@/composables/useExportTracker"; import { useFileDrop } from "@/composables/useFileDrop"; import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback"; import { usePanelResize } from "@/composables/usePanelResize"; +import { loadUiTuning } from "@/lib/app/uiTuning"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import { useSqlExecution } from "@/composables/useSqlExecution"; import MultiDbExecuteDialog from "@/components/editor/MultiDbExecuteDialog.vue"; @@ -364,6 +365,7 @@ const rightSidebarPanelStorageKeys: Partial> let lastOpenedRightSidebarPanel = RIGHT_SIDEBAR_PANEL_IDS.find((panelId) => rightSidebarPanelRefs[panelId].value); const sidebarOpen = ref(safeLocalStorageGet("dbx-sidebar-open") !== "false"); const aiPanelReady = ref(false); +void loadUiTuning(); const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, sqlFilePanelWidth, tabBarWidth, tabBarCollapsed, startSidebarResize, startAiPanelResize, startHistoryResize, startSqlLibraryResize, startSqlFilePanelResize, startLeftTabBarResize, startRightTabBarResize, setTabBarCollapsed } = usePanelResize(); const aiAssistantRef = ref(null); diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index db85a8f986..8ef87675d5 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -153,6 +153,8 @@ import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter"; import { copySqlAsRichText } from "@/lib/sql/sqlRichText"; import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, loadEditorTheme, editorFontTheme, shellLineCommentTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes"; import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter"; +import { isPanelResizing } from "@/lib/app/panelResizeState"; +import { uiTuning } from "@/lib/app/uiTuning"; import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut"; import { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler, createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; import { createQueryEditorEscapeHandler } from "@/lib/editor/queryEditorEscape"; @@ -297,6 +299,90 @@ const emit = defineEmits<{ }>(); const editorRef = ref(); + +// While any panel divider is dragged, CodeMirror re-wraps and re-measures +// every visible line on every frame the editor box changes — with a 100+ line +// script that is the dominant drag cost (profiler: 2k+ measure() calls with +// getBoundingClientRect in a 3s drag). We pin the editor root to an explicit +// pixel size and then step-update that pin at most every TRACK_EVERY_FRAMES +// animation frames (~30fps), which keeps the editor visually following the +// divider while cutting the relayout frequency (and the layout thrash from +// interleaved reads/writes) by half or more. The step interval is tunable via +// ~/.dbx/ui-tuning.json (panelResizeTrackEveryFrames). The pin is released +// once on pointer-up for one final exact layout. +let frozenEditorBox: { width: number; height: number } | null = null; +let editorTrackFrameId = 0; +let editorTrackFrameCount = 0; + +function editorAvailableSize(): { width: number; height: number } | null { + const host = editorRef.value?.parentElement; + if (!host) return null; + const width = host.clientWidth; + const height = host.clientHeight; + if (width <= 0 || height <= 0) return null; + return { width, height }; +} + +function pinEditorBox(size: { width: number; height: number }) { + const root = editorRef.value; + if (!root) return; + frozenEditorBox = size; + root.style.width = `${Math.round(size.width)}px`; + root.style.height = `${Math.round(size.height)}px`; + root.style.maxWidth = "none"; + root.style.minHeight = "0"; +} + +function releaseEditorBox() { + const root = editorRef.value; + frozenEditorBox = null; + if (!root) return; + root.style.width = ""; + root.style.height = ""; + root.style.maxWidth = ""; + root.style.minHeight = ""; +} + +function stopEditorBoxTracking() { + if (editorTrackFrameId) { + cancelAnimationFrame(editorTrackFrameId); + editorTrackFrameId = 0; + } + editorTrackFrameCount = 0; +} + +function editorBoxTrackStep() { + editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep); + if (++editorTrackFrameCount < uiTuning.value.panelResizeTrackEveryFrames) return; + editorTrackFrameCount = 0; + const next = editorAvailableSize(); + if (!next || !frozenEditorBox) return; + if (Math.round(next.width) === Math.round(frozenEditorBox.width) && Math.round(next.height) === Math.round(frozenEditorBox.height)) return; + // Writing the pin keeps the editor a fixed-size box: the parent's layout is + // reflowed (cheap, the editor subtree is `contain`ed and skipped) but + // CodeMirror only observes one discrete size change per step. + pinEditorBox(next); +} + +watch( + isPanelResizing, + (resizing) => { + const root = editorRef.value; + if (!root) return; + if (resizing) { + if (frozenEditorBox) return; + const size = editorAvailableSize(); + if (!size) return; + pinEditorBox(size); + editorTrackFrameId = requestAnimationFrame(editorBoxTrackStep); + } else { + stopEditorBoxTracking(); + if (frozenEditorBox) releaseEditorBox(); + } + }, + { flush: "sync" }, +); +onBeforeUnmount(stopEditorBoxTracking); const view = shallowRef(null); const contextMenuOpen = ref(false); let contextMenuPointerCleanup: (() => void) | null = null; diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 5766435107..aefa6e389c 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -296,6 +296,8 @@ import { useDataGridColumnLayout, useDataGridColumnLayoutState, type ColumnHeade import { createColumnReferencePayload, createTableReferenceDragEndEvent, createTableReferenceDropEvent, createTableReferenceHoverEvent } from "@/lib/editor/queryEditorTableDrop"; import { beginTableReferenceDragFeedback, isOverSqlEditorTarget, type TableReferenceDragFeedback } from "@/lib/editor/tableReferenceDragFeedback"; import { dataGridCanvasDevicePixelSize, useDataGridCanvasRuntime, type DataGridCanvasRuntime } from "@/composables/useDataGridCanvasRuntime"; +import { deferUntilPanelResizeEnd, isPanelResizing } from "@/lib/app/panelResizeState"; +import { uiTuning } from "@/lib/app/uiTuning"; import { useDataGridScrollbars, type DataGridScrollbarsRuntime } from "@/composables/useDataGridScrollbars"; import { useDataGridSelection } from "@/composables/useDataGridSelection"; import { dataGridNavigationOrigin, dataGridPageScrollTop, dataGridRowScrollTop, moveDataGridCell, navigateDataGridCell, type DataGridNavigationDirection, type DataGridScrollAlignment } from "@/lib/dataGrid/dataGridNavigation"; @@ -2591,43 +2593,56 @@ function observeGridHorizontalScrollbarScroller() { gridScrollbarsRuntime.observeScroller(); } +let dataGridTopbarRafId: number | null = null; + function updateDataGridTopbarWidth() { const topbar = dataGridTopbarRef.value; - const previousActionCount = compactDataGridToolbarActionCount.value; - dataGridTopbarWidth.value = topbar?.clientWidth ?? 0; - dataGridViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth; - if (!topbar) return; - if (compactDataGridToolbarActionCount.value !== previousActionCount) { - scheduleDataGridTopbarRecheck(); - return; - } + // Reading scrollWidth/clientWidth here forces a document-wide synchronous + // relayout; while a divider drag is in flight that stacks on top of + // CodeMirror's measurement. Defer to a single re-measure when the drag ends. + if (deferUntilPanelResizeEnd(updateDataGridTopbarWidth)) return; - if (dataGridTopbarOverflowCompact.value || dataGridTopbarOverflowActionCount.value > 0) { - if (dataGridTopbarWidth.value >= dataGridTopbarExpandedRequiredWidth.value) { - dataGridTopbarOverflowCompact.value = false; - dataGridTopbarOverflowActionCount.value = 0; - dataGridTopbarExpandedRequiredWidth.value = 0; + const performMeasure = () => { + dataGridTopbarRafId = null; + const currentTopbar = dataGridTopbarRef.value; + if (!currentTopbar) return; + + const previousActionCount = compactDataGridToolbarActionCount.value; + dataGridTopbarWidth.value = currentTopbar.clientWidth ?? 0; + dataGridViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth; + + if (compactDataGridToolbarActionCount.value !== previousActionCount) { scheduleDataGridTopbarRecheck(); return; } - } - // The action list gains preview/save/rollback controls in editable grids. The - // responsive breakpoint cannot see that extra width, so progressively - // compact the leftmost controls before overflow-clip hides a trailing action. - if (topbar.scrollWidth > topbar.clientWidth + 1) { - dataGridTopbarExpandedRequiredWidth.value = Math.max(dataGridTopbarExpandedRequiredWidth.value, topbar.scrollWidth + DATA_GRID_TOPBAR_EXPAND_HYSTERESIS_PX); - if (!compactDataGridToolbar.value) { - dataGridTopbarOverflowCompact.value = true; - } else if (compactDataGridToolbarActionCount.value < DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.length) { - dataGridTopbarOverflowActionCount.value = compactDataGridToolbarActionCount.value + 1; - } else { - return; + if (dataGridTopbarOverflowCompact.value || dataGridTopbarOverflowActionCount.value > 0) { + if (dataGridTopbarWidth.value >= dataGridTopbarExpandedRequiredWidth.value) { + dataGridTopbarOverflowCompact.value = false; + dataGridTopbarOverflowActionCount.value = 0; + dataGridTopbarExpandedRequiredWidth.value = 0; + scheduleDataGridTopbarRecheck(); + return; + } } - scheduleDataGridTopbarRecheck(); - } + + if (currentTopbar.scrollWidth > currentTopbar.clientWidth + 1) { + dataGridTopbarExpandedRequiredWidth.value = Math.max(dataGridTopbarExpandedRequiredWidth.value, currentTopbar.scrollWidth + DATA_GRID_TOPBAR_EXPAND_HYSTERESIS_PX); + if (!compactDataGridToolbar.value) { + dataGridTopbarOverflowCompact.value = true; + } else if (compactDataGridToolbarActionCount.value < DATA_GRID_TOOLBAR_ACTION_COLLAPSE_ORDER.length) { + dataGridTopbarOverflowActionCount.value = compactDataGridToolbarActionCount.value + 1; + } else { + return; + } + scheduleDataGridTopbarRecheck(); + } + }; + + if (dataGridTopbarRafId !== null) cancelAnimationFrame(dataGridTopbarRafId); + dataGridTopbarRafId = requestAnimationFrame(performMeasure); } function resetDataGridTopbarOverflowCompact() { @@ -6576,7 +6591,7 @@ function dataGridRowFromClientPoint(_clientX: number, clientY: number): number | return Number.isInteger(rowIndex) ? rowIndex : null; } -function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) { +function syncCanvasViewportRaw(entries?: readonly ResizeObserverEntry[]) { if (!dataGridIsActive) return; const scroller = canvasScrollerElement(); if (!scroller) return; @@ -6591,6 +6606,45 @@ function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) { canvasRuntime?.drawNow(); } +// Public entry: ResizeObserver / imperative callers. During divider drags the +// step tracker owns refreshes, so plain notifications are dropped here. +function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) { + if (!dataGridIsActive) return; + if (isPanelResizing.value) return; + syncCanvasViewportRaw(entries); +} + +// During divider drags the ResizeObserver would fire every frame; re-measure +// and redraw the canvas on a fixed step instead (same cadence as the SQL +// editor's pinned-box tracking, tunable via ~/.dbx/ui-tuning.json) so the +// grid follows the drag at reduced cost. +let canvasTrackFrameId = 0; +let canvasTrackFrameCount = 0; + +function canvasTrackStep() { + canvasTrackFrameId = requestAnimationFrame(canvasTrackStep); + if (++canvasTrackFrameCount < uiTuning.value.panelResizeTrackEveryFrames) return; + canvasTrackFrameCount = 0; + syncCanvasViewportRaw(); +} + +function stopCanvasResizeTracking() { + if (canvasTrackFrameId) { + cancelAnimationFrame(canvasTrackFrameId); + canvasTrackFrameId = 0; + } + canvasTrackFrameCount = 0; +} + +watch(isPanelResizing, (resizing) => { + if (resizing) { + if (!canvasTrackFrameId) canvasTrackFrameId = requestAnimationFrame(canvasTrackStep); + } else { + stopCanvasResizeTracking(); + syncCanvasViewportRaw(); + } +}); + function currentCanvasDevicePixelRatio(): number { return typeof window === "undefined" ? 1 : Math.max(1, window.devicePixelRatio || 1); } @@ -7266,6 +7320,7 @@ function pauseCanvasGridWork() { gridRef.value?.setAttribute("data-grid-active", "false"); if (gridSurfaceBusy.value) finishDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner); canvasRuntime.pause(); + stopCanvasResizeTracking(); gridScrollbarsRuntime.pause(); disconnectCellEditResizeObserver(); dataGridTopbarResizeObserver?.disconnect(); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 99b05ec47c..8efa4154b2 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -155,6 +155,7 @@ import { tabularResultItems, type ExecutionSummaryItem, } from "@/lib/tabs/tabPresentation"; +import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState"; import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive"; import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { isTableDataEditable } from "@/lib/table/tableEditing"; @@ -708,7 +709,11 @@ function handleHideResultsPane() { } } +function onResultsSplitResize() { + beginPanelResize(); +} function onResultsResized(payload: { panes: { size: number }[] }) { + endPanelResize(); const resultsPane = payload.panes[1]; if (resultsPane?.size != null && resultsPane.size >= 20 && resultsPane.size <= 85) { resultsPaneSize.value = resultsPane.size; @@ -1428,7 +1433,7 @@ defineExpose({