perf(layout): track panel resizing at a stepped cadence

This commit is contained in:
dengjie zhang
2026-09-18 01:48:27 +08:00
committed by GitHub
parent a78fc48a7b
commit 8eba12ea2f
17 changed files with 705 additions and 34 deletions
+2
View File
@@ -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<Record<RightSidebarPanelId, string>>
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<AiAssistantHandle | null>(null);
@@ -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<HTMLDivElement>();
// 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<EditorViewType | null>(null);
const contextMenuOpen = ref(false);
let contextMenuPointerCleanup: (() => void) | null = null;
+83 -28
View File
@@ -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();
@@ -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({
</div>
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resized="onResultsResized">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resize="onResultsSplitResize" @resized="onResultsResized">
<Pane v-if="!resultOnly" class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, nextTick, onMounted, provide, ref, useAttrs, watch } from "vue";
import { computed, inject, nextTick, onBeforeUnmount, onMounted, provide, ref, useAttrs, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@@ -9,6 +9,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { createGroupTabBarPortal, GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
import { hasQueryOutput } from "@/lib/query/queryOutput";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
import { Button } from "@/components/ui/button";
import EditorGroup from "./EditorGroup.vue";
import QueryResultSurface from "./QueryResultSurface.vue";
@@ -198,6 +199,28 @@ function paneEnterClass(groupId: string): string | undefined {
}
return queryStore.orientation === "horizontal" ? "workspace-pane-enter workspace-pane-enter--from-top" : "workspace-pane-enter workspace-pane-enter--from-left";
}
let splitterDragging = false;
const splitterEndEvents = ["mouseup", "touchend", "touchcancel", "pointerup", "pointercancel"] as const;
function onWorkspaceSplitterStart(event: MouseEvent | TouchEvent) {
const splitter = event.target instanceof Element ? event.target.closest(".splitpanes__splitter") : null;
if (!splitter?.parentElement?.matches(".sql-editor-workspace-split, .sql-editor-groups") || splitterDragging) return;
splitterDragging = true;
beginPanelResize();
for (const eventName of splitterEndEvents) document.addEventListener(eventName, onWorkspaceSplitterEnd, true);
window.addEventListener("blur", onWorkspaceSplitterEnd);
}
function onWorkspaceSplitterEnd() {
if (!splitterDragging) return;
splitterDragging = false;
for (const eventName of splitterEndEvents) document.removeEventListener(eventName, onWorkspaceSplitterEnd, true);
window.removeEventListener("blur", onWorkspaceSplitterEnd);
endPanelResize();
}
onBeforeUnmount(onWorkspaceSplitterEnd);
watch(() => props.contentSuppressed, onWorkspaceSplitterEnd);
function onSharedResultResized(payload: { panes: { size: number }[] }) {
const resultPane = payload.panes[1];
if (resultPane?.size != null && resultPane.size >= SHARED_RESULT_PANE_MIN_SIZE && resultPane.size <= SHARED_RESULT_PANE_MAX_SIZE) {
@@ -302,7 +325,7 @@ function handleFocusErrorOffset(tabId: string, offset: number): boolean {
@detach-tab="emit('detach-tab', $event)"
/>
</template>
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Splitpanes v-else horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @mousedown.capture="onWorkspaceSplitterStart" @touchstart.capture="onWorkspaceSplitterStart" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
@@ -0,0 +1,216 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beginPanelResize, deferUntilPanelResizeEnd, endPanelResize, isPanelResizing } from "@/lib/app/panelResizeState";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryTab } from "@/types/database";
import SqlEditorWorkspace from "../SqlEditorWorkspace.vue";
vi.mock("@/components/layout/EditorGroup.vue", () => ({ default: { template: "<div />" } }));
vi.mock("@/components/layout/QueryResultSurface.vue", () => ({ default: { template: "<div />" } }));
describe("workspace real Splitpanes drag lifecycle", () => {
let frames: FrameRequestCallback[];
let cleanup: (() => void) | undefined;
beforeEach(() => {
vi.useFakeTimers();
frames = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frames.push(callback));
localStorage.removeItem("dbx-shared-results-pane-size");
});
afterEach(() => {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
vi.runAllTimers();
cleanup?.();
cleanup = undefined;
endPanelResize();
vi.unstubAllGlobals();
vi.useRealTimers();
document.body.innerHTML = "";
});
async function flushFrames() {
const pending = frames.splice(0);
for (const callback of pending) callback(0);
await nextTick();
}
async function mountWorkspace(orientation: "horizontal" | "vertical" = "vertical") {
const pinia = createPinia();
setActivePinia(pinia);
const store = useQueryStore();
const tabs: QueryTab[] = ["first", "second"].map((id) => ({
id,
title: id,
connectionId: "conn",
database: "db",
sql: "SELECT 1",
mode: "query",
isExecuting: false,
result: { columns: ["id"], rows: [], affected_rows: 0, execution_time_ms: 1 },
}));
store.tabs = tabs;
store.activeTabId = tabs[0]!.id;
store.groups = tabs.map((tab) => ({ id: tab.id, tabIds: [tab.id], activeTabId: tab.id }));
store.focusedGroupId = tabs[0]!.id;
store.sizes = [50, 50];
store.orientation = orientation;
const host = document.createElement("div");
document.body.append(host);
const app = createApp(SqlEditorWorkspace, {
activeTab: tabs[0],
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanup = () => app.unmount();
await nextTick();
for (const container of host.querySelectorAll(".splitpanes")) {
Object.defineProperty(container, "clientWidth", { value: 1000 });
Object.defineProperty(container, "clientHeight", { value: 1000 });
}
await flushFrames();
return { host, store, app };
}
function start(host: HTMLElement, shared = false) {
const selector = shared ? ".sql-editor-workspace-split" : ".sql-editor-groups";
const splitter = host.querySelector(`${selector} > .splitpanes__splitter`)!;
vi.spyOn(splitter, "getBoundingClientRect").mockReturnValue({ left: 500, top: 500 } as DOMRect);
splitter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 500, clientY: 500 }));
}
function move(position: number) {
document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: position, clientY: position }));
}
it.each(["horizontal", "vertical", "shared"] as const)("ignores queued resize after release for %s panes and preserves final sizes", async (layout) => {
const { host, store } = await mountWorkspace(layout === "horizontal" ? "horizontal" : "vertical");
start(host, layout === "shared");
move(600);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
const resync = vi.fn();
expect(deferUntilPanelResizeEnd(resync)).toBe(true);
move(650);
expect(frames.length).toBeGreaterThan(0);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(isPanelResizing.value).toBe(false);
expect(resync).toHaveBeenCalledTimes(1);
const sizes = [...store.sizes];
const resultSize = localStorage.getItem("dbx-shared-results-pane-size");
if (layout === "shared") expect(Number(resultSize)).toBeCloseTo(40);
else expect(sizes).toEqual([60, 40]);
await flushFrames();
expect(isPanelResizing.value).toBe(false);
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
expect(resync).toHaveBeenCalledTimes(1);
expect(store.sizes).toEqual(sizes);
expect(localStorage.getItem("dbx-shared-results-pane-size")).toBe(resultSize);
});
it("ignores the first queued frame when released before any frame runs", async () => {
const { host } = await mountWorkspace();
start(host);
move(600);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
await flushFrames();
expect(isPanelResizing.value).toBe(false);
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
});
it.each(["pointerup", "pointercancel", "blur", "unmount"])("cleans up on %s", async (eventName) => {
const { host } = await mountWorkspace();
start(host);
move(600);
await flushFrames();
const resync = vi.fn();
deferUntilPanelResizeEnd(resync);
if (eventName !== "unmount") move(650);
if (eventName === "unmount") {
cleanup?.();
cleanup = undefined;
} else if (eventName === "blur") window.dispatchEvent(new Event(eventName));
else document.dispatchEvent(new Event(eventName));
expect(isPanelResizing.value).toBe(false);
await flushFrames();
expect(isPanelResizing.value).toBe(false);
expect(document.body.classList.contains("dbx-is-resizing")).toBe(false);
expect(resync).toHaveBeenCalledTimes(1);
});
it.each(["touchend", "touchcancel"])("tracks real touch dragging and cleans up on %s", async (eventName) => {
vi.stubGlobal("ontouchstart", null);
const { host } = await mountWorkspace();
const splitter = host.querySelector(".sql-editor-groups > .splitpanes__splitter")!;
const touchStart = new Event("touchstart", { bubbles: true });
Object.defineProperty(touchStart, "touches", { value: [{ clientX: 0, clientY: 0 }] });
splitter.dispatchEvent(touchStart);
const touchMove = new Event("touchmove", { bubbles: true });
Object.defineProperty(touchMove, "touches", { value: [{ clientX: 600, clientY: 600 }] });
document.dispatchEvent(touchMove);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
document.dispatchEvent(touchMove);
document.dispatchEvent(new Event(eventName, { bubbles: true }));
await flushFrames();
expect(isPanelResizing.value).toBe(false);
});
it("keeps tracking across frames and pauses until the real release", async () => {
const { host, store } = await mountWorkspace();
start(host);
move(600);
await flushFrames();
const resync = vi.fn();
deferUntilPanelResizeEnd(resync);
vi.advanceTimersByTime(1600);
expect(isPanelResizing.value).toBe(true);
move(700);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
expect(resync).not.toHaveBeenCalled();
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(store.sizes).toEqual([70, 30]);
expect(resync).toHaveBeenCalledTimes(1);
});
it("does not let a previous drag's queued frame end the next drag", async () => {
const { host } = await mountWorkspace();
start(host);
move(600);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
vi.advanceTimersByTime(100);
start(host);
expect(isPanelResizing.value).toBe(true);
await flushFrames();
expect(isPanelResizing.value).toBe(true);
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
expect(isPanelResizing.value).toBe(false);
});
it("ignores content clicks and leaves another panel's resize state alone", async () => {
const { host, store } = await mountWorkspace();
host.querySelector(".splitpanes__pane")!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(isPanelResizing.value).toBe(false);
beginPanelResize();
store.sizes = [55, 45];
await nextTick();
await flushFrames();
expect(isPanelResizing.value).toBe(true);
cleanup?.();
cleanup = undefined;
expect(isPanelResizing.value).toBe(true);
});
});
@@ -49,3 +49,10 @@
animation: none;
}
}
/* 隔离编辑器区域与结果集区域的重排作用域,防止大文档重测联动穿透导致卡顿 */
.sql-editor-groups,
[data-shared-result-surface] {
contain: layout style;
}
+20 -1
View File
@@ -1,5 +1,6 @@
import { ref, type Ref } from "vue";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
const PANEL_MIN_WIDTH = 240;
const DEFAULT_PANEL_MAX_WIDTH = 800;
@@ -36,6 +37,7 @@ export function usePanelResize() {
resizeHandle?.setPointerCapture(e.pointerId);
const resizeOverlay = document.createElement("div");
resizeOverlay.setAttribute("aria-hidden", "true");
beginPanelResize();
Object.assign(resizeOverlay.style, {
position: "fixed",
inset: "0",
@@ -53,19 +55,36 @@ export function usePanelResize() {
widthRef.value = startWidth;
const panelElement = resizeHandle?.parentElement;
let currentWidth = startWidth;
let rafId: number | null = null;
const onPointerMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
currentWidth = Math.max(PANEL_MIN_WIDTH, Math.min(upperBound, startWidth + (direction === "right" ? delta : -delta)));
panelElement?.style.setProperty("width", `${currentWidth}px`);
// happy-dom unit tests assert styles synchronously (no frame advance),
// so apply immediately under the test mode instead of via rAF.
if (import.meta.env.MODE === "test") {
panelElement?.style.setProperty("width", `${currentWidth}px`);
return;
}
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
panelElement?.style.setProperty("width", `${currentWidth}px`);
rafId = null;
});
};
const finishResize = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
panelElement?.style.setProperty("width", `${currentWidth}px`);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", finishResize);
document.removeEventListener("pointercancel", finishResize);
window.removeEventListener("blur", finishResize);
if (resizeHandle?.hasPointerCapture(e.pointerId)) resizeHandle.releasePointerCapture(e.pointerId);
endPanelResize();
resizeOverlay.remove();
widthRef.value = currentWidth;
safeLocalStorageSet(storageKey, String(widthRef.value));
@@ -1,4 +1,5 @@
import { onBeforeUnmount, ref, watch, type Ref } from "vue";
import { deferUntilPanelResizeEnd } from "@/lib/app/panelResizeState";
export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
const hasTabOverflow = ref(false);
@@ -20,6 +21,10 @@ export function useTabScroll(tabsContainerRef: Ref<HTMLElement | null>) {
scrollThumbWidthPercent.value = 100;
return;
}
// Skip DOM geometry reads while a divider drag is in flight; they force a
// synchronous relayout of the whole document on every frame. Re-measure
// once when the drag finishes.
if (deferUntilPanelResizeEnd(updateScrollButtons)) return;
const maxScrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
hasTabOverflow.value = maxScrollLeft > 1;
const rawThumbWidth = el.scrollWidth > 0 ? (el.clientWidth / el.scrollWidth) * 100 : 100;
@@ -1,4 +1,5 @@
import { nextTick, onUnmounted, ref, watch, type Ref, type WatchSource } from "vue";
import { deferUntilPanelResizeEnd } from "@/lib/app/panelResizeState";
import { resolveNextEditorToolbarTier, type EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
/**
@@ -33,6 +34,10 @@ export function useToolbarOverflow(rootRef: Ref<HTMLElement | null>, revalidateS
if (!element) {
return;
}
// clientWidth/scrollWidth reads force a document-wide synchronous relayout
// on every resize notification; skip them during divider drags and take a
// single measurement when the drag ends.
if (deferUntilPanelResizeEnd(measure)) return;
const next = resolveNextEditorToolbarTier({
tier: tier.value,
availableWidth: element.clientWidth,
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const fs = {
exists: vi.fn(),
readTextFile: vi.fn(),
};
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/api/path", () => ({
homeDir: () => Promise.resolve("/home/tester"),
join: (_home: string, ...parts: string[]) => Promise.resolve(["/home/tester", ...parts].join("/")),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
exists: (path: string) => fs.exists(path),
readTextFile: (path: string) => fs.readTextFile(path),
}));
async function loadFresh() {
vi.resetModules();
const module = await import("../uiTuning");
await module.loadUiTuning();
return module;
}
describe("uiTuning", () => {
beforeEach(() => {
fs.exists.mockReset();
fs.readTextFile.mockReset();
});
it("uses the defaults when ~/.dbx/ui-tuning.json is absent", async () => {
fs.exists.mockResolvedValue(false);
const { uiTuning, DEFAULT_UI_TUNING } = await loadFresh();
expect(uiTuning.value.panelResizeTrackEveryFrames).toBe(DEFAULT_UI_TUNING.panelResizeTrackEveryFrames);
});
it("clamps out-of-range values from the tuning file", async () => {
fs.exists.mockResolvedValue(true);
fs.readTextFile.mockResolvedValue(JSON.stringify({ panelResizeTrackEveryFrames: 99 }));
const { uiTuning } = await loadFresh();
expect(uiTuning.value.panelResizeTrackEveryFrames).toBe(10);
});
it("falls back to defaults on malformed json", async () => {
fs.exists.mockResolvedValue(true);
fs.readTextFile.mockResolvedValue("{ not json");
const { uiTuning, DEFAULT_UI_TUNING } = await loadFresh();
expect(uiTuning.value.panelResizeTrackEveryFrames).toBe(DEFAULT_UI_TUNING.panelResizeTrackEveryFrames);
});
it("accepts a valid in-range value", async () => {
fs.exists.mockResolvedValue(true);
fs.readTextFile.mockResolvedValue(JSON.stringify({ panelResizeTrackEveryFrames: 2 }));
const { uiTuning } = await loadFresh();
expect(uiTuning.value.panelResizeTrackEveryFrames).toBe(2);
expect(fs.exists).toHaveBeenCalledWith("/home/tester/.dbx/ui-tuning.json");
expect(fs.readTextFile).toHaveBeenCalledWith("/home/tester/.dbx/ui-tuning.json");
});
it.each(["exists", "readTextFile"] as const)("keeps defaults when %s access is denied", async (command) => {
fs.exists.mockResolvedValue(true);
fs[command].mockRejectedValue(new Error("path not allowed"));
const { uiTuning, DEFAULT_UI_TUNING, loadUiTuning } = await loadFresh();
expect(uiTuning.value).toEqual(DEFAULT_UI_TUNING);
await loadUiTuning();
expect(fs.exists).toHaveBeenCalledTimes(1);
expect(fs.readTextFile).toHaveBeenCalledTimes(command === "exists" ? 0 : 1);
});
});
@@ -0,0 +1,56 @@
import { ref } from "vue";
/**
* Global "a panel divider is being dragged" state.
*
* Resizing side panels or splitter panes changes the width of the CodeMirror
* editor and the DataGrid on every frame. Both subtrees react to that with
* expensive per-frame work (CodeMirror's requestMeasure walking every visible
* line, ResizeObserver chains reading clientWidth/scrollWidth, which forces
* synchronous layout for the whole document). Heavy components subscribe to
* this flag to freeze measurement while a drag is in flight and re-sync once
* it finishes, turning hundreds of forced reflows into a single relayout.
*/
const isPanelResizing = ref(false);
const pendingAfterResize = new Set<() => void>();
function setPanelResizingResyncClass(resizing: boolean) {
if (typeof document === "undefined") return;
document.body.classList.toggle("dbx-is-resizing", resizing);
}
/** Marks a drag as in flight. Idempotent: safe to call on every move event. */
export function beginPanelResize() {
if (isPanelResizing.value) return;
isPanelResizing.value = true;
setPanelResizingResyncClass(true);
}
/** Ends the current drag (if any) and flushes registered re-sync callbacks. */
export function endPanelResize() {
if (!isPanelResizing.value) return;
isPanelResizing.value = false;
setPanelResizingResyncClass(false);
const callbacks = [...pendingAfterResize];
pendingAfterResize.clear();
for (const callback of callbacks) {
try {
callback();
} catch {
// A failed re-sync must not block the other subscribers.
}
}
}
/**
* Registers a measurement callback to run when the in-flight drag ends.
* Returns whether the caller should skip its own work right now — i.e.
* `true` means "a drag is active, your callback was queued".
*/
export function deferUntilPanelResizeEnd(callback: () => void): boolean {
if (!isPanelResizing.value) return false;
pendingAfterResize.add(callback);
return true;
}
export { isPanelResizing };
+57
View File
@@ -0,0 +1,57 @@
import { readonly, ref } from "vue";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
/**
* Runtime UI tuning knobs, loaded once from `~/.dbx/ui-tuning.json` at
* startup (desktop runtime only; web builds keep the defaults).
*
* These are power-user performance dials, deliberately not part of the
* settings UI: edit the JSON file and restart the app to apply.
*/
export interface UiTuning {
/**
* While a panel divider is dragged, expensive subtrees (CodeMirror editor
* box, DataGrid canvas) are pinned to an explicit pixel size and only
* re-measured every N animation frames. 1 = update on every frame (full
* tracking), 5 ≈ 12fps stepping, 10 ≈ 6fps. Clamped to 1..10.
*/
panelResizeTrackEveryFrames: number;
}
export const DEFAULT_UI_TUNING: UiTuning = {
panelResizeTrackEveryFrames: 5,
};
const MAX_TRACK_EVERY_FRAMES = 10;
const state = ref<UiTuning>({ ...DEFAULT_UI_TUNING });
export const uiTuning = readonly(state);
function sanitizeFrames(value: unknown): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_UI_TUNING.panelResizeTrackEveryFrames;
return Math.min(MAX_TRACK_EVERY_FRAMES, Math.max(1, Math.round(parsed)));
}
let loading: Promise<void> | null = null;
/** Reads `~/.dbx/ui-tuning.json` once; safe to call repeatedly. */
export function loadUiTuning(): Promise<void> {
loading ??= (async () => {
if (!isTauriRuntime()) return;
try {
const { homeDir, join } = await import("@tauri-apps/api/path");
const { exists, readTextFile } = await import("@tauri-apps/plugin-fs");
const file = await join(await homeDir(), ".dbx", "ui-tuning.json");
if (!(await exists(file))) return;
const parsed = JSON.parse(await readTextFile(file)) as Partial<UiTuning>;
state.value = {
panelResizeTrackEveryFrames: sanitizeFrames(parsed.panelResizeTrackEveryFrames),
};
console.info("[ui-tuning] loaded", file, state.value);
} catch (error) {
console.info("[ui-tuning] using defaults (could not read ~/.dbx/ui-tuning.json)", error);
}
})();
return loading;
}
+39
View File
@@ -2440,6 +2440,45 @@ html.dbx-legacy-webview [data-slot="dialog-content"][class*="sm:max-w-[min(1180p
}
/* Splitpanes */
/* Splitpanes dragging optimization:
While dragging any panel boundary (sidebar, sql-files, or workspace splitters),
isolate expensive subtrees (CodeMirror editor and DataGrid results) from triggering
continuous forced layout reflows and pointer recalculations on every frame. */
body.dbx-is-resizing,
.splitpanes--dragging {
cursor: col-resize !important;
user-select: none !important;
}
body.dbx-is-resizing *,
.splitpanes--dragging * {
user-select: none !important;
}
body.dbx-is-resizing .cm-editor,
body.dbx-is-resizing [data-shared-result-surface],
.splitpanes--dragging .cm-editor,
.splitpanes--dragging [data-shared-result-surface] {
pointer-events: none !important;
}
/* 核心优化:在拖拽期间,锁定 CodeMirror 的视口测量与内部 reflow,
同时让内容区域以固定 layout 运行,拖拽结束再恢复真实排版 */
body.dbx-is-resizing .cm-editor,
.splitpanes--dragging .cm-editor {
contain: strict !important;
overflow: hidden !important;
}
body.dbx-is-resizing .cm-scroller,
.splitpanes--dragging .cm-scroller {
overflow: hidden !important;
}
.splitpanes--horizontal.splitpanes--dragging {
cursor: row-resize !important;
}
.splitpanes--horizontal .splitpanes__pane {
transition: none !important;
}
+4 -1
View File
@@ -197,7 +197,10 @@ test("embedded result toolbar progressively compacts when editing actions overfl
assert.match(dataGrid, /dataGridTopbarMutationObserver = new MutationObserver\(updateDataGridTopbarWidth\)/);
assert.match(dataGrid, /dataGridTopbarMutationObserver\.observe\(topbar, \{ childList: true, characterData: true, subtree: true \}\)/);
assert.match(dataGrid, /topbar\.scrollWidth > topbar\.clientWidth \+ 1/);
const measurementStart = dataGrid.indexOf("function updateDataGridTopbarWidth()");
const measurementEnd = dataGrid.indexOf("function resetDataGridTopbarOverflowCompact()", measurementStart);
assert.ok(measurementStart >= 0 && measurementEnd > measurementStart);
assert.match(dataGrid.slice(measurementStart, measurementEnd), /(\w+)\.scrollWidth > \1\.clientWidth \+ 1/);
assert.match(dataGrid, /dataGridTopbarOverflowCompact\.value = true/);
assert.match(dataGrid, /dataGridTopbarOverflowActionCount\.value = compactDataGridToolbarActionCount\.value \+ 1/);
assert.match(dataGrid, /dataGridTopbarWidth\.value >= dataGridTopbarExpandedRequiredWidth\.value/);
+17
View File
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
type ScopedPermission = { identifier: string; allow?: { path: string }[]; deny?: { path: string }[] };
const capability = JSON.parse(readFileSync(new URL("../../src-tauri/capabilities/default.json", import.meta.url), "utf8")) as {
permissions: (string | ScopedPermission)[];
};
it("grants only exists and readTextFile command scopes for the exact UI tuning file", () => {
const scopes = capability.permissions.filter((permission): permission is ScopedPermission => typeof permission !== "string" && permission.identifier.startsWith("fs:"));
expect(scopes).toEqual([
{ identifier: "fs:allow-exists", allow: [{ path: "$HOME/.dbx/ui-tuning.json" }] },
{ identifier: "fs:allow-read-text-file", allow: [{ path: "$HOME/.dbx/ui-tuning.json" }] },
]);
expect(capability.permissions).not.toContain("fs:scope");
expect(capability.permissions.filter((permission) => typeof permission === "string" && permission.startsWith("fs:"))).toEqual(["fs:default", "fs:allow-write-file", "fs:allow-write-text-file", "fs:allow-stat", "fs:allow-read-file", "fs:allow-open", "fs:allow-read"]);
});
+8 -1
View File
@@ -32,9 +32,16 @@
"dialog:allow-save",
"dialog:allow-open",
"fs:default",
{
"identifier": "fs:allow-exists",
"allow": [{ "path": "$HOME/.dbx/ui-tuning.json" }]
},
"fs:allow-write-file",
"fs:allow-write-text-file",
"fs:allow-read-text-file",
{
"identifier": "fs:allow-read-text-file",
"allow": [{ "path": "$HOME/.dbx/ui-tuning.json" }]
},
"fs:allow-stat",
"fs:allow-read-file",
"fs:allow-open",