fix(grid): improve scrolling and sidebar responsiveness
This commit is contained in:
parent
67541232a2
commit
29b351691a
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 }" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue