mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-12 02:23:37 +08:00
perf(layout): track panel resizing at a stepped cadence
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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'"
|
||||
|
||||
+216
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"]);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user