fix(grid): improve scrolling and sidebar responsiveness

This commit is contained in:
juve 2026-07-13 16:12:54 +08:00 committed by GitHub
parent 67541232a2
commit 29b351691a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 142 additions and 41 deletions

View File

@ -19,6 +19,7 @@ import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeFor
import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget";
import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
import TreeItem from "./TreeItem.vue";
import { RecycleScroller } from "vue-virtual-scroller";
@ -520,6 +521,8 @@ function onSidebarScrollbarThumbPointerDown(event: PointerEvent) {
window.addEventListener("pointercancel", stopSidebarScrollbarDrag);
}
const pasteHandlerRegistry = createSidebarPasteHandlerRegistry();
provide(sidebarTreeContextKey, {
getVisibleNodes: () => selectableVisibleNodes.value,
getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1,
@ -528,6 +531,7 @@ provide(sidebarTreeContextKey, {
store.setSidebarTableSearchQuery(parentNodeId, query);
scheduleSidebarTableSearchRefresh(parentNodeId, { restoreFocus: true });
},
registerPasteHandler: pasteHandlerRegistry.register,
});
const pendingRenameGroupId = ref<string | null>(null);
@ -1032,8 +1036,8 @@ function requestSelectedSidebarPaste(): boolean {
return true;
}
if (clipboard?.kind !== "table-copy" || clipboard.tables.length === 0 || !selectedNodeId) return false;
window.dispatchEvent(new CustomEvent("dbx:sidebar-request-paste-table", { detail: { nodeId: selectedNodeId } }));
return true;
return pasteHandlerRegistry.request(selectedNodeId);
}
onMounted(() => {

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, inject, type Component } from "vue";
import { ref, computed, nextTick, watch, onBeforeUnmount, inject, type Component } from "vue";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
@ -227,18 +227,22 @@ function scheduleLabelOverflowMeasure() {
});
}
function observeLabelOverflow() {
labelResizeObserver?.disconnect();
labelResizeObserver = null;
function handleMouseEnter() {
if (!shouldMeasureLabelOverflow()) {
labelOverflowing.value = false;
return;
}
if (typeof ResizeObserver !== "undefined" && labelRef.value) {
updateLabelOverflow();
if (typeof ResizeObserver !== "undefined" && labelRef.value && !labelResizeObserver) {
labelResizeObserver = new ResizeObserver(scheduleLabelOverflowMeasure);
labelResizeObserver.observe(labelRef.value);
}
scheduleLabelOverflowMeasure();
}
function handleMouseLeave() {
labelResizeObserver?.disconnect();
labelResizeObserver = null;
cancelLabelOverflowMeasure();
}
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
@ -992,12 +996,6 @@ function requestPasteTreeClipboard(): boolean {
return true;
}
function onSidebarRequestPasteTable(event: Event) {
const nodeId = (event as CustomEvent<{ nodeId?: string }>).detail?.nodeId;
if (nodeId !== props.node.id) return;
requestPasteTreeClipboard();
}
function requestRefreshSelectedNode(): boolean {
if (!canRefreshTreeNodeShortcut()) return false;
void refresh();
@ -1299,6 +1297,12 @@ async function openData() {
queryStore.setExecutingWithId(tabId, openDataId);
logPhase("state-prepared", { tabId });
// Yield to Vue's scheduler so the new tab becomes visible in the UI (tab
// bar activates, content area switches) before the first blocking network
// call. Without this the entire openData flow runs synchronously before the
// browser paints, making the UI feel frozen on each table click.
await nextTick();
// Helper to check if this openData call is still active (not superseded by a newer click)
const isActive = () => queryStore.tabs.find((t) => t.id === tabId)?.executionId === openDataId;
const isCurrentDataTab = () => {
@ -4180,7 +4184,7 @@ const isConnected = computed(() => props.node.type === "connection" && !!props.n
const isConnecting = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectingIds.has(props.node.connectionId));
const isConnectionReadonly = computed(() => props.node.type === "connection" && !!props.node.connectionId && (connectionStore.getConfig(props.node.connectionId)?.read_only ?? false));
const isOpenedDatabase = computed(() => isSidebarDatabaseOpened(props.node, connectionStore.isTreeNodeChildrenLoaded));
const showsDatabaseOpenIndicator = computed(() => props.node.type === "database" && (isOpenedDatabase.value || (!!props.node.connectionId && props.node.database != null && queryStore.isDatabaseOpen(props.node.connectionId, props.node.database))));
const showsDatabaseOpenIndicator = computed(() => props.node.type === "database" && (isOpenedDatabase.value || (!!props.node.connectionId && props.node.database != null && queryStore.openDatabaseKeys.has(`${props.node.connectionId}\x00${props.node.database}`))));
const canCloseDatabaseConnection = computed(() => canCloseSidebarDatabaseConnection(props.node, connectionStore.isTreeNodeChildrenLoaded));
const nodeIconClass = computed(() => {
const infoClass = getIconInfo(props.node)?.colorClass;
@ -4222,7 +4226,7 @@ const connectionColor = computed(() => {
});
const isActiveConnectionScope = computed(() => !!props.node.connectionId && connectionStore.activeConnectionId === props.node.connectionId);
const isSelected = computed(() => connectionStore.selectedTreeNodeId === props.node.id);
const isMultiSelected = computed(() => connectionStore.selectedTreeNodeIds.includes(props.node.id));
const isMultiSelected = computed(() => connectionStore.selectedTreeNodeIdsSet.has(props.node.id));
const isTreeRowSelected = computed(() => isSelected.value || isMultiSelected.value);
const usesSelectionSetHighlight = computed(() => connectionStore.connectionMultiSelectActive || connectionStore.selectedTreeNodeIds.length > 1);
const rowStyle = computed(() => {
@ -4307,14 +4311,6 @@ watch(
{ immediate: true },
);
watch(
[() => props.node.id, () => visibleLabel(props.node), () => usesFullWidthLabel.value, () => detailTooltip.value?.rows.length ?? 0, isRenamingGroup],
() => {
nextTick(observeLabelOverflow);
},
{ flush: "post", immediate: true },
);
function finishRenameGroup() {
// Guard against double invocation: pressing Enter sets isRenamingGroup=false
// and unmounts the input, which then fires @blur -> finishRenameGroup again.
@ -4545,21 +4541,23 @@ function onRowMouseDown(event: MouseEvent) {
}
}
onMounted(() => {
observeLabelOverflow();
window.addEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable);
});
// RecycleScroller reuses mounted TreeItem instances for different nodes, so the
// handler must follow the reactive node id rather than component mount lifetime.
const stopPasteHandlerRegistration = watch(
() => props.node.id,
(nodeId, _previousNodeId, onCleanup) => {
const unregister = sidebarTreeContext?.registerPasteHandler?.(nodeId, requestPasteTreeClipboard);
if (unregister) onCleanup(unregister);
},
{ immediate: true },
);
onBeforeUnmount(() => {
labelResizeObserver?.disconnect();
labelResizeObserver = null;
cancelLabelOverflowMeasure();
window.removeEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable);
handleMouseLeave();
stopPasteHandlerRegistration();
finishTableReferenceDrag();
});
// ---- CustomContextMenu ----
const shortcutCopyName = computed(() => settingsStore.editorSettings.shortcuts.copySidebarSelection);
const shortcutEditConnection = computed(() => settingsStore.editorSettings.shortcuts.editSidebarConnection);
const shortcutRename = "F2";
@ -5322,7 +5320,11 @@ function treeItemMenuItems(): ContextMenuItem[] {
@keydown="onKeydown"
@mousedown="onRowMouseDown"
@mousemove="isDropTarget ? updateTarget($event, node.id, node.type) : undefined"
@mouseleave="clearTarget(node.id)"
@mouseenter="handleMouseEnter"
@mouseleave="
clearTarget(node.id);
handleMouseLeave();
"
>
<div v-if="showDropBefore" class="absolute right-2 top-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />
<div v-if="showDropAfter" class="absolute right-2 bottom-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />

View File

@ -381,16 +381,14 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.font = value === null ? italicFont : tabularFont;
setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums");
const textLeft = alignCanvasPixel(drawX + 12, dpr);
const paddedMaxWidth = Math.max(0, drawX + colWidth - textLeft - 12);
const cellMaxWidth = Math.max(0, colWidth - 24);
const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx;
const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx);
const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText);
const needsTruncation = ctx.measureText(displayText).width > paddedMaxWidth;
const textMaxWidth = needsTruncation ? Math.max(0, drawX + colWidth - textLeft) : paddedMaxWidth;
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, textMaxWidth - 12);
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth);
ctx.fillText(text, textLeft, textY);
if (item.isDeleted && text) {
const textWidth = Math.min(ctx.measureText(text).width, textMaxWidth);
const textWidth = ctx.measureText(text).width;
ctx.strokeStyle = theme.foreground;
ctx.beginPath();
ctx.moveTo(textLeft, textY);

View File

@ -0,0 +1,40 @@
export type SidebarPasteHandler = () => void;
export interface SidebarPasteHandlerRegistry {
register: (nodeId: string, handler: SidebarPasteHandler) => () => void;
request: (nodeId: string) => boolean;
}
export function createSidebarPasteHandlerRegistry(): SidebarPasteHandlerRegistry {
const handlersByNodeId = new Map<string, Set<SidebarPasteHandler>>();
function register(nodeId: string, handler: SidebarPasteHandler) {
let handlers = handlersByNodeId.get(nodeId);
if (!handlers) {
handlers = new Set();
handlersByNodeId.set(nodeId, handlers);
}
handlers.add(handler);
return () => {
const currentHandlers = handlersByNodeId.get(nodeId);
if (!currentHandlers) return;
currentHandlers.delete(handler);
if (currentHandlers.size === 0) handlersByNodeId.delete(nodeId);
};
}
function request(nodeId: string): boolean {
const handlers = handlersByNodeId.get(nodeId);
if (!handlers?.size) return false;
// The sticky database row may duplicate a virtualized row. Prefer the most
// recently registered live instance while keeping older owners as fallback.
let activeHandler: SidebarPasteHandler | undefined;
for (const handler of handlers) activeHandler = handler;
activeHandler?.();
return true;
}
return { register, request };
}

View File

@ -5,6 +5,7 @@ export interface SidebarTreeContext {
getVisibleNodes: () => TreeNode[];
getVisibleNodeIndex: (id: string) => number;
setTableSearchQuery?: (parentNodeId: string, query: string) => void;
registerPasteHandler?: (nodeId: string, callback: () => void) => () => void;
}
export const sidebarTreeContextKey: InjectionKey<SidebarTreeContext> = Symbol("sidebar-tree-context");

View File

@ -218,6 +218,10 @@ export const useConnectionStore = defineStore("connection", () => {
const activeConnectionId = ref<string | null>(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY));
const selectedTreeNodeId = ref<string | null>(null);
const selectedTreeNodeIds = ref<string[]>([]);
// O(1) membership set — rebuilds only when selectedTreeNodeIds changes.
// Avoids O(N) Array.includes() in every visible TreeItem's isMultiSelected
// computed during scrolling and selection changes.
const selectedTreeNodeIdsSet = computed(() => new Set(selectedTreeNodeIds.value));
const treeSelectionAnchorId = ref<string | null>(null);
const connectionMultiSelectActive = ref(false);
const treeClipboard = ref<TreeClipboard | null>(null);
@ -5062,6 +5066,7 @@ export const useConnectionStore = defineStore("connection", () => {
activeConnectionId,
selectedTreeNodeId,
selectedTreeNodeIds,
selectedTreeNodeIdsSet,
treeSelectionAnchorId,
connectionMultiSelectActive,
treeClipboard,

View File

@ -326,6 +326,21 @@ export const useQueryStore = defineStore("query", () => {
const t = getI18nT();
const settingsStore = useSettingsStore();
const tabs = ref<QueryTab[]>([]);
// A stable Set of "connectionId\x00database" keys. Computed only from the
// minimal tab identity fields so that it does NOT invalidate when other
// properties change (isExecuting, result, sql, tableMeta...). Previously
// isDatabaseOpen() called tabs.value.some() which tracked the full reactive
// array — every mutation during openData() forced all database-type sidebar
// TreeItems to recompute showsDatabaseOpenIndicator.
const openDatabaseKeys = computed(() => {
const keys = new Set<string>();
for (const tab of tabs.value) {
if (tab.connectionId && tab.database != null) {
keys.add(`${tab.connectionId}\x00${tab.database}`);
}
}
return keys;
});
const activeTabId = ref<string | null>(null);
const isOpenTabsLoaded = ref(false);
const activeTabHistory = ref<string[]>([]);
@ -1665,7 +1680,7 @@ export const useQueryStore = defineStore("query", () => {
}
function isDatabaseOpen(connectionId: string, database: string) {
return tabs.value.some((tab) => tab.connectionId === connectionId && tab.database === database);
return openDatabaseKeys.value.has(`${connectionId}\x00${database}`);
}
function rollbackTabsWhere(predicate: (tab: QueryTab) => boolean, options?: { resetAutoCommit?: boolean }) {
@ -3776,6 +3791,7 @@ export const useQueryStore = defineStore("query", () => {
releaseConnectionTabs,
releaseDatabaseTabs,
isDatabaseOpen,
openDatabaseKeys,
rollbackConnectionTransactions,
rollbackDatabaseTransactions,
updateSql,

View File

@ -0,0 +1,35 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createSidebarPasteHandlerRegistry } from "../../apps/desktop/src/lib/sidebar/sidebarPasteHandlerRegistry.ts";
test("unregistering one duplicate owner keeps the other handler active", () => {
const registry = createSidebarPasteHandlerRegistry();
const calls: string[] = [];
const unregisterVirtualRow = registry.register("database-1", () => calls.push("virtual"));
const unregisterStickyRow = registry.register("database-1", () => calls.push("sticky"));
assert.equal(registry.request("database-1"), true);
assert.deepEqual(calls, ["sticky"]);
unregisterStickyRow();
assert.equal(registry.request("database-1"), true);
assert.deepEqual(calls, ["sticky", "virtual"]);
unregisterVirtualRow();
assert.equal(registry.request("database-1"), false);
});
test("recycled row cleanup removes only its previous node registration", () => {
const registry = createSidebarPasteHandlerRegistry();
const calls: string[] = [];
const unregisterFirstNode = registry.register("table-1", () => calls.push("first"));
unregisterFirstNode();
const unregisterSecondNode = registry.register("table-2", () => calls.push("second"));
assert.equal(registry.request("table-1"), false);
assert.equal(registry.request("table-2"), true);
assert.deepEqual(calls, ["second"]);
unregisterSecondNode();
});