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

This commit is contained in:
dengjie zhang authored and GitHub committed 2026-09-18 01:48:27 +08:00
1 parent a78fc48a7b
commit 8eba12ea2f
17 files changed
+705 -34

No files matched your search

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