fix(sidebar): centralize tree action handling

This commit is contained in:
t8y2 2026-07-14 18:10:24 +08:00
parent 7f096653db
commit ee85033068
28 changed files with 2705 additions and 1032 deletions

View File

@ -6,10 +6,10 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { TreeNode, TreeNodeType } from "@/types/database";
import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
import { copyNameForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { copyNameForTreeNode, objectSourceKindForTreeNode } from "@/lib/sidebar/treeNodeClick";
import { copyToClipboard } from "@/lib/common/clipboard";
import { connectionPasteTargetGroupId, selectedConnectionClipboardNodes, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch";
@ -17,14 +17,24 @@ import { usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeForTarget, findNodePathForTarget, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection, type ActiveTabSidebarTarget, type SidebarNodeScrollAlign } from "@/lib/sidebar/sidebarActiveTabTarget";
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 { createFlatTreeIndex, 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 SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately, type SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect";
import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { createSidebarActionTarget, findSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
import { resetSidebarTreeDialogState } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
const { t } = useI18n();
const store = useConnectionStore();
@ -39,6 +49,37 @@ const pointerInsideTree = ref(false);
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
const plainTreeScrollerRef = ref<HTMLElement | null>(null);
const sidebarScrollbarTrackRef = ref<HTMLElement | null>(null);
const sidebarContextMenuRef = ref<{ close: () => void } | null>(null);
const sidebarContextMenuItems = ref<ContextMenuItem[]>([]);
const sidebarContextMenuTarget = ref<SidebarActionTarget | null>(null);
const sidebarDangerDialogRequest = ref<SidebarDangerDialogRequest | null>(null);
const sidebarDangerDialogOpen = ref(false);
const sidebarDangerDialogConfirming = ref(false);
const sidebarTreeItemDialogController = ref<Record<string, any> | null>(null);
const sidebarInstallExtensionTarget = ref<TreeNode | null>(null);
const sidebarInstallExtensionDialogRef = ref<InstanceType<typeof InstallExtensionDialog> | null>(null);
const sidebarDdlTarget = ref<TreeNode | null>(null);
const sidebarDdlOpen = ref(false);
const sidebarObjectSourceTarget = ref<{ node: TreeNode; initialEditing: boolean } | null>(null);
const sidebarObjectSourceOpen = ref(false);
const sidebarProcedureTarget = ref<TreeNode | null>(null);
const sidebarProcedureOpen = ref(false);
const sidebarVisibleDatabasesTarget = ref<TreeNode | null>(null);
const sidebarVisibleDatabasesOpen = ref(false);
const sidebarVisibleSchemasTarget = ref<TreeNode | null>(null);
const sidebarVisibleSchemasOpen = ref(false);
let sidebarActionGeneration = 0;
const sidebarDdlDatabaseType = computed(() => {
const connectionId = sidebarDdlTarget.value?.connectionId;
return connectionId ? effectiveDatabaseTypeForConnection(store.getConfig(connectionId)) : undefined;
});
const sidebarObjectSourceType = computed(() => (sidebarObjectSourceTarget.value ? objectSourceKindForTreeNode(sidebarObjectSourceTarget.value.node.type) : null));
const sidebarObjectSourceDatabaseType = computed(() => {
const connectionId = sidebarObjectSourceTarget.value?.node.connectionId;
return connectionId ? effectiveDatabaseTypeForConnection(store.getConfig(connectionId)) : undefined;
});
const sidebarObjectSourceDialect = computed(() => codeMirrorSqlDialect(sidebarObjectSourceDatabaseType.value));
const sidebarObjectSourceFormatDialect = computed(() => sqlFormatDialectForDbType(sidebarObjectSourceDatabaseType.value));
type SearchScope = "connection" | "database" | "schema" | "table" | "view";
const selectedSearchScopes = ref<SearchScope[]>([]);
const searchCollapsedIds = ref<Set<string>>(new Set());
@ -288,13 +329,19 @@ const flatNodes = computed<FlatTreeNode[]>(() =>
activeQueries: store.sidebarTableSearchQueries,
}),
);
const visibleNodes = computed<TreeNode[]>(() => flatNodes.value.map((item) => item.node));
const selectableVisibleNodes = computed<TreeNode[]>(() => visibleNodes.value.filter((node) => !isSidebarTableSearchControlNode(node)));
const selectableVisibleNodeIndexById = computed(() => {
const next = new Map<string, number>();
selectableVisibleNodes.value.forEach((node, index) => next.set(node.id, index));
return next;
});
// Build all lookup tables in one linear pass whenever the visible tree changes.
// Selection, scrolling and sticky headers then avoid repeated full-array scans.
const flatTreeIndex = computed(() =>
createFlatTreeIndex(flatNodes.value, {
isSelectable: (node) => !isSidebarTableSearchControlNode(node),
isBoundary: (type) => type === "connection" || type === "connection-group",
isDatabaseContainer: (type) => DATABASE_LEVEL_TYPES.has(type),
isSchemaContainer: (type) => SCHEMA_LEVEL_TYPES.has(type),
}),
);
const visibleNodes = computed<TreeNode[]>(() => flatTreeIndex.value.visibleNodes);
const selectableVisibleNodes = computed<TreeNode[]>(() => flatTreeIndex.value.selectableVisibleNodes);
const selectableVisibleNodeIndexById = computed(() => flatTreeIndex.value.selectableVisibleNodeIndexById);
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
@ -382,51 +429,19 @@ const stickyNode = computed<FlatTreeNode | null>(() => {
if (len === 0) return null;
const topIndex = Math.min(Math.floor(stickyScrollTop.value / SIDEBAR_TREE_ROW_HEIGHT), len - 1);
// flatNodes is a DFS preorder spanning ALL connections, so walking up from a
// leaf visits `... -> schema -> database -> connection -> <other connection>`.
// Stop at the connection boundary so the sticky row never leaks across into a
// different connection's subtree (e.g. MySQL's last database sticking while
// scrolling Dameng). Within one connection: track both candidates and prefer
// database-level; only fall back to schema when the whole path has no
// database-level container (Dameng/Oracle-style trees).
let schemaCandidate: FlatTreeNode | null = null;
let schemaCandidateTop = 0;
for (let i = topIndex; i >= 0; i--) {
const item = nodes[i];
if (item.type === "connection" || item.type === "connection-group") break;
if (DATABASE_LEVEL_TYPES.has(item.type)) {
const rowTop = i * SIDEBAR_TREE_ROW_HEIGHT;
return stickyScrollTop.value > rowTop ? item : null;
}
if (item.type === "schema" && !schemaCandidate) {
schemaCandidate = item;
schemaCandidateTop = i * SIDEBAR_TREE_ROW_HEIGHT;
}
}
if (!schemaCandidate) return null;
return stickyScrollTop.value > schemaCandidateTop ? schemaCandidate : null;
const containerIndex = flatTreeIndex.value.stickyContainerIndexByIndex[topIndex] ?? -1;
if (containerIndex < 0) return null;
return stickyScrollTop.value > containerIndex * SIDEBAR_TREE_ROW_HEIGHT ? nodes[containerIndex] : null;
});
const stickyHeaderStyle = computed<CSSProperties>(() => {
const node = stickyNode.value;
if (!node) return {};
const nodes = flatNodes.value;
const currentIndex = nodes.findIndex((item) => item.id === node.id);
const currentIndex = flatTreeIndex.value.flatNodeIndexById.get(node.id) ?? -1;
if (currentIndex < 0) return {};
// Look forward for the next sibling container at the SAME level as the sticky
// node so the push-up only fires when a peer scrolls in (database-to-database,
// or schema-to-schema for Dameng/Oracle), never schema-into-database. Stop at
// the connection boundary so we never reach into the next connection's rows.
const nextTypes = SCHEMA_LEVEL_TYPES.has(node.type) ? SCHEMA_LEVEL_TYPES : DATABASE_LEVEL_TYPES;
let nextDatabaseIndex = -1;
for (let i = currentIndex + 1; i < nodes.length; i++) {
const item = nodes[i];
if (item.type === "connection" || item.type === "connection-group") break;
if (nextTypes.has(item.type)) {
nextDatabaseIndex = i;
break;
}
}
// The next peer index is precomputed with the flat-tree snapshot so scrolling
// never scans the remaining tree. Connection boundaries reset the lookup.
const nextDatabaseIndex = SCHEMA_LEVEL_TYPES.has(node.type) ? flatTreeIndex.value.nextSchemaContainerIndexByIndex[currentIndex] : flatTreeIndex.value.nextDatabaseContainerIndexByIndex[currentIndex];
if (nextDatabaseIndex < 0) return {};
const distanceToNext = nextDatabaseIndex * SIDEBAR_TREE_ROW_HEIGHT - stickyScrollTop.value;
if (distanceToNext >= SIDEBAR_TREE_ROW_HEIGHT) return {};
@ -438,6 +453,11 @@ const stickyHeaderStyle = computed<CSSProperties>(() => {
// Reset tracking when the tree rebuilds (connect/disconnect/collapse) so a
// stale scrollTop doesn't keep the overlay mounted after a structural change.
watch(flatNodes, () => {
// Menu actions originate from a rendered row instance. Close the singleton
// before a structural update can recycle that row onto another node.
sidebarContextMenuRef.value?.close();
sidebarContextMenuItems.value = [];
sidebarContextMenuTarget.value = null;
stickyScrollTop.value = 0;
void nextTick(scheduleSidebarScrollMetricsUpdate);
});
@ -567,7 +587,7 @@ function topOcclusionHeightForSidebarNode(nodeId: string): number {
async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNodeScrollAlign }) {
await nextTick();
const index = flatNodes.value.findIndex((item) => item.id === nodeId);
const index = flatTreeIndex.value.flatNodeIndexById.get(nodeId) ?? -1;
const scroller = currentTreeScroller();
if (!scroller || index < 0) return;
@ -845,10 +865,177 @@ function onSearchToggle(node: TreeNode) {
searchCollapsedIds.value = next;
}
function openSidebarContextMenu(event: MouseEvent, node: TreeNode, items: ContextMenuItem[], openContextMenu: (event: MouseEvent, itemsOverride?: ContextMenuItem[]) => void) {
sidebarContextMenuTarget.value = createSidebarActionTarget(node);
sidebarContextMenuItems.value = items;
// Pass the current row's resolved menu atomically. Waiting for the items prop
// to flush would let the singleton menu briefly reuse the previous row menu.
openContextMenu(event, items);
}
function openSidebarDangerDialog(request: SidebarDangerDialogRequest) {
sidebarDangerDialogRequest.value = request;
sidebarDangerDialogConfirming.value = false;
sidebarDangerDialogOpen.value = true;
}
async function confirmSidebarDangerDialog() {
const request = sidebarDangerDialogRequest.value;
if (!request || sidebarDangerDialogConfirming.value) return;
if (request.closeOnConfirm !== false) sidebarDangerDialogOpen.value = false;
sidebarDangerDialogConfirming.value = true;
try {
await request.confirm();
sidebarDangerDialogOpen.value = false;
} finally {
sidebarDangerDialogConfirming.value = false;
}
}
function updateSidebarDangerDialogOption(event: Event) {
const option = sidebarDangerDialogRequest.value?.option;
if (!option) return;
option.checked = (event.target as HTMLInputElement).checked;
void option.onChange?.(option.checked);
}
function updateSidebarTreeItemDialogController(controller: Record<string, any> | null) {
sidebarTreeItemDialogController.value = controller;
}
async function openSidebarInstallExtension(node: TreeNode) {
sidebarInstallExtensionTarget.value = createSidebarActionTarget(node);
await nextTick();
sidebarInstallExtensionDialogRef.value?.show();
}
function beginSidebarAction(): number {
sidebarActionGeneration += 1;
sidebarDdlOpen.value = false;
sidebarObjectSourceOpen.value = false;
sidebarProcedureOpen.value = false;
sidebarVisibleDatabasesOpen.value = false;
sidebarVisibleSchemasOpen.value = false;
sidebarDdlTarget.value = null;
sidebarObjectSourceTarget.value = null;
sidebarProcedureTarget.value = null;
sidebarVisibleDatabasesTarget.value = null;
sidebarVisibleSchemasTarget.value = null;
return sidebarActionGeneration;
}
function tableDdlObjectTypeForSidebarNode(type: TreeNodeType): ObjectSourceKind | undefined {
if (type === "view") return "VIEW";
if (type === "materialized_view") return "MATERIALIZED_VIEW";
return undefined;
}
function openSidebarDdl(node: TreeNode) {
if (!node.connectionId || !node.database) return;
beginSidebarAction();
sidebarDdlTarget.value = createSidebarActionTarget(node);
sidebarDdlOpen.value = true;
}
function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
if (!node.connectionId || !node.database || !objectSourceKindForTreeNode(node.type)) return;
const target = createSidebarActionTarget(node);
const requestGeneration = beginSidebarAction();
void store
.ensureConnected(target.connectionId!)
.then(() => {
if (requestGeneration !== sidebarActionGeneration) return;
store.activeConnectionId = target.connectionId!;
sidebarObjectSourceTarget.value = { node: target, initialEditing };
sidebarObjectSourceOpen.value = true;
})
.catch((error: any) => {
if (requestGeneration === sidebarActionGeneration) toast(error?.message || String(error), 5000);
});
}
function openSidebarProcedure(node: TreeNode) {
if (node.type !== "procedure" || !node.connectionId || !node.database) return;
beginSidebarAction();
sidebarProcedureTarget.value = createSidebarActionTarget(node);
sidebarProcedureOpen.value = true;
}
function openSidebarData(node: TreeNode, requireSelection: boolean, runner: (node: TreeNode, request: SidebarDataOpenRequest) => Promise<void>) {
const target = createSidebarActionTarget(node);
runSidebarDataOpenImmediately((request) => {
if (requireSelection && store.selectedTreeNodeId !== target.id) return;
return runner(target, request);
});
}
function openSidebarVisibleDatabases(node: TreeNode) {
if (node.type !== "connection" || !node.connectionId) return;
beginSidebarAction();
sidebarVisibleDatabasesTarget.value = createSidebarActionTarget(node);
sidebarVisibleDatabasesOpen.value = true;
}
function openSidebarVisibleSchemas(node: TreeNode) {
if ((node.type !== "connection" && node.type !== "database") || !node.connectionId) return;
const database = node.type === "database" ? node.database : store.getConfig(node.connectionId)?.database;
if (database == null) return;
beginSidebarAction();
sidebarVisibleSchemasTarget.value = createSidebarActionTarget({ ...node, database });
sidebarVisibleSchemasOpen.value = true;
}
function openSidebarProcedureSql(sql: string) {
const target = sidebarProcedureTarget.value;
if (!target?.connectionId || !target.database || !sql) return;
const tabId = queryStore.createTab(target.connectionId, target.database, `Execute - ${target.label}`, "query", target.schema);
queryStore.updateSql(tabId, sql);
}
async function executeSidebarProcedureSql(sql: string) {
const target = sidebarProcedureTarget.value;
if (!target?.connectionId || !target.database || !sql) return;
const tabId = queryStore.createTab(target.connectionId, target.database, `Execute - ${target.label}`, "query", target.schema);
queryStore.updateSql(tabId, sql);
await queryStore.executeTabSql(tabId, sql);
}
async function refreshSidebarActionTarget() {
const target = sidebarObjectSourceTarget.value?.node || sidebarDdlTarget.value;
if (!target) return;
const currentTarget = findSidebarActionTarget(store.treeNodes, target);
if (!currentTarget) return;
try {
await store.refreshTreeNode(currentTarget);
} catch (error: any) {
toast(error?.message || String(error), 5000);
}
}
watch(sidebarDdlOpen, (open) => {
if (!open) sidebarDdlTarget.value = null;
});
watch(sidebarObjectSourceOpen, (open) => {
if (!open) sidebarObjectSourceTarget.value = null;
});
watch(sidebarProcedureOpen, (open) => {
if (!open) sidebarProcedureTarget.value = null;
});
watch(sidebarVisibleDatabasesOpen, (open) => {
if (!open) sidebarVisibleDatabasesTarget.value = null;
});
watch(sidebarVisibleSchemasOpen, (open) => {
if (!open) sidebarVisibleSchemasTarget.value = null;
});
function collapseAllTreeNodes() {
store.collapseAllTreeNodes();
if (isSearching.value) {
searchCollapsedIds.value = new Set(flatNodes.value.filter((item) => item.node.children?.length).map((item) => item.id));
searchCollapsedIds.value = new Set(flatTreeIndex.value.expandableNodeIds);
}
}
@ -866,7 +1053,7 @@ async function selectActiveTabSidebarNode(options: { scroll: boolean }) {
await nextTick();
const index = flatNodes.value.findIndex((item) => item.id === match.id);
const index = flatTreeIndex.value.flatNodeIndexById.get(match.id) ?? -1;
const scroller = currentTreeScroller();
if (!scroller || index < 0) return;
@ -985,7 +1172,7 @@ function isEditConnectionShortcut(event: KeyboardEvent): boolean {
function requestSelectedConnectionEdit(): boolean {
const selectedNodeId = store.selectedTreeNodeId;
const currentNode = selectedNodeId ? visibleNodes.value.find((node) => node.id === selectedNodeId) : null;
const currentNode = selectedNodeId ? flatTreeIndex.value.nodeById.get(selectedNodeId) : null;
if (!currentNode) return false;
const editTarget = selectedConnectionEditTarget(currentNode, selectedSidebarNodesInVisibleOrder());
if (!editTarget) return false;
@ -1025,7 +1212,7 @@ function requestSelectedSidebarPaste(): boolean {
const clipboard = store.treeClipboard;
const selectedNodeId = store.selectedTreeNodeId;
if (clipboard?.kind === "connection-copy") {
const selectedNode = selectedNodeId ? visibleNodes.value.find((node) => node.id === selectedNodeId) : null;
const selectedNode = selectedNodeId ? flatTreeIndex.value.nodeById.get(selectedNodeId) : null;
const targetGroupId = connectionPasteTargetGroupId(selectedNode, (connectionId) => store.groupIdForConnection(connectionId));
void store
.pasteConnectionClipboard(targetGroupId)
@ -1045,7 +1232,19 @@ onMounted(() => {
});
onUnmounted(() => {
sidebarActionGeneration += 1;
sidebarContextMenuTarget.value = null;
sidebarContextMenuItems.value = [];
sidebarDdlTarget.value = null;
sidebarObjectSourceTarget.value = null;
sidebarProcedureTarget.value = null;
sidebarVisibleDatabasesTarget.value = null;
sidebarVisibleSchemasTarget.value = null;
sidebarTreeItemDialogController.value = null;
sidebarDangerDialogRequest.value = null;
resetSidebarTreeDialogState();
window.removeEventListener("keydown", onWindowKeydown);
cancelPendingSidebarDataOpen();
for (const timer of tableSearchTimers.values()) {
window.clearTimeout(timer);
}
@ -1107,60 +1306,172 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
/>
</div>
</div>
<div v-if="flatNodes.length > 0 && useVirtualTree" class="connection-tree-scroll-shell relative min-h-0 flex-1">
<RecycleScroller
ref="treeScrollerRef"
class="sidebar-tree connection-tree-scroller h-full overflow-y-auto"
:class="sidebarTreeOverflowClass"
@click="clearSidebarSelection"
:items="flatNodes"
:item-size="SIDEBAR_TREE_ROW_HEIGHT"
:buffer="SIDEBAR_TREE_SCROLL_BUFFER"
:prerender="SIDEBAR_TREE_PRERENDER_COUNT"
:skip-hover="true"
key-field="id"
type-field="poolType"
flow-mode
>
<template #default="{ item }">
<CustomContextMenu ref="sidebarContextMenuRef" :items="sidebarContextMenuItems" v-slot="contextMenuSlot">
<div v-if="flatNodes.length > 0 && useVirtualTree" class="connection-tree-scroll-shell relative min-h-0 flex-1">
<RecycleScroller
ref="treeScrollerRef"
class="sidebar-tree connection-tree-scroller h-full overflow-y-auto"
:class="sidebarTreeOverflowClass"
@click="clearSidebarSelection"
:items="flatNodes"
:item-size="SIDEBAR_TREE_ROW_HEIGHT"
:buffer="SIDEBAR_TREE_SCROLL_BUFFER"
:prerender="SIDEBAR_TREE_PRERENDER_COUNT"
:skip-hover="true"
key-field="id"
type-field="poolType"
flow-mode
>
<template #default="{ item }">
<TreeItem
:node="item.node"
:depth="item.depth"
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.node.id"
@search-toggle="onSearchToggle"
@context-menu="(event, node, items) => openSidebarContextMenu(event, node, items, contextMenuSlot.onContextMenu)"
@open-ddl="openSidebarDdl"
@open-object-source="openSidebarObjectSource"
@open-procedure="openSidebarProcedure"
@open-data="openSidebarData"
@open-visible-databases="openSidebarVisibleDatabases"
@open-visible-schemas="openSidebarVisibleSchemas"
@open-danger-dialog="openSidebarDangerDialog"
@open-dialog-controller="updateSidebarTreeItemDialogController"
@open-install-extension="openSidebarInstallExtension"
@rename-started="pendingRenameGroupId = null"
@group-created="startRenamingCreatedGroup"
/>
</template>
</RecycleScroller>
<div v-if="stickyNode" class="sticky-database-header pointer-events-auto absolute inset-x-0 top-0 z-[5] border-b border-border/60" :style="stickyHeaderStyle">
<TreeItem
:node="stickyNode.node"
:depth="stickyNode.depth"
:drag-disabled="true"
@search-toggle="onSearchToggle"
@context-menu="(event, node, items) => openSidebarContextMenu(event, node, items, contextMenuSlot.onContextMenu)"
@open-ddl="openSidebarDdl"
@open-object-source="openSidebarObjectSource"
@open-procedure="openSidebarProcedure"
@open-data="openSidebarData"
@open-visible-databases="openSidebarVisibleDatabases"
@open-visible-schemas="openSidebarVisibleSchemas"
@open-danger-dialog="openSidebarDangerDialog"
@open-dialog-controller="updateSidebarTreeItemDialogController"
@open-install-extension="openSidebarInstallExtension"
/>
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
</div>
<div v-else-if="flatNodes.length > 0" class="connection-tree-scroll-shell relative min-h-0 flex-1">
<div ref="plainTreeScrollerRef" class="sidebar-tree connection-tree-scroller h-full overflow-y-auto" :class="sidebarTreeOverflowClass" @click="clearSidebarSelection" @scroll.passive="onTreeScroll">
<TreeItem
v-for="item in flatNodes"
:key="item.id"
:node="item.node"
:depth="item.depth"
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.node.id"
:highlighted="highlightedNodeId === item.id"
@search-toggle="onSearchToggle"
@context-menu="(event, node, items) => openSidebarContextMenu(event, node, items, contextMenuSlot.onContextMenu)"
@open-ddl="openSidebarDdl"
@open-object-source="openSidebarObjectSource"
@open-procedure="openSidebarProcedure"
@open-data="openSidebarData"
@open-visible-databases="openSidebarVisibleDatabases"
@open-visible-schemas="openSidebarVisibleSchemas"
@open-danger-dialog="openSidebarDangerDialog"
@open-dialog-controller="updateSidebarTreeItemDialogController"
@open-install-extension="openSidebarInstallExtension"
@rename-started="pendingRenameGroupId = null"
@group-created="startRenamingCreatedGroup"
/>
</template>
</RecycleScroller>
<div v-if="stickyNode" class="sticky-database-header pointer-events-auto absolute inset-x-0 top-0 z-[5] border-b border-border/60" :style="stickyHeaderStyle">
<TreeItem :node="stickyNode.node" :depth="stickyNode.depth" :drag-disabled="true" @search-toggle="onSearchToggle" />
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
</div>
<div v-else-if="flatNodes.length > 0" class="connection-tree-scroll-shell relative min-h-0 flex-1">
<div ref="plainTreeScrollerRef" class="sidebar-tree connection-tree-scroller h-full overflow-y-auto" :class="sidebarTreeOverflowClass" @click="clearSidebarSelection" @scroll.passive="onTreeScroll">
<TreeItem
v-for="item in flatNodes"
:key="item.id"
:node="item.node"
:depth="item.depth"
:drag-disabled="isFiltering"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
@search-toggle="onSearchToggle"
@rename-started="pendingRenameGroupId = null"
@group-created="startRenamingCreatedGroup"
/>
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
</div>
</div>
</CustomContextMenu>
<SidebarDdlViewDialog
v-if="sidebarDdlTarget"
v-model:open="sidebarDdlOpen"
:connection-id="sidebarDdlTarget.connectionId!"
:database="sidebarDdlTarget.database!"
:schema="sidebarDdlTarget.schema"
:table-name="sidebarDdlTarget.label"
:object-type="tableDdlObjectTypeForSidebarNode(sidebarDdlTarget.type)"
:database-type="sidebarDdlDatabaseType"
:dialect="codeMirrorSqlDialect(sidebarDdlDatabaseType)"
:format-dialect="sqlFormatDialectForDbType(sidebarDdlDatabaseType)"
/>
<SidebarObjectSourceDialog
v-if="sidebarObjectSourceTarget && sidebarObjectSourceType"
v-model:open="sidebarObjectSourceOpen"
:connection-id="sidebarObjectSourceTarget.node.connectionId!"
:database="sidebarObjectSourceTarget.node.database!"
:schema="sidebarObjectSourceTarget.node.schema"
:name="sidebarObjectSourceTarget.node.objectName || sidebarObjectSourceTarget.node.label"
:signature="sidebarObjectSourceTarget.node.signature"
:object-type="sidebarObjectSourceType"
:database-type="sidebarObjectSourceDatabaseType"
:dialect="sidebarObjectSourceDialect"
:format-dialect="sidebarObjectSourceFormatDialect"
:initial-editing="sidebarObjectSourceTarget.initialEditing"
@saved="refreshSidebarActionTarget"
/>
<SidebarProcedureExecutionDialog
v-if="sidebarProcedureTarget?.connectionId && sidebarProcedureTarget.database"
v-model:open="sidebarProcedureOpen"
:connection-id="sidebarProcedureTarget.connectionId"
:database="sidebarProcedureTarget.database"
:database-type="effectiveDatabaseTypeForConnection(store.getConfig(sidebarProcedureTarget.connectionId))"
:schema="sidebarProcedureTarget.schema"
:routine-name="sidebarProcedureTarget.label"
@open-sql="openSidebarProcedureSql"
@execute="executeSidebarProcedureSql"
/>
<SidebarVisibleDatabasesDialog v-if="sidebarVisibleDatabasesTarget?.connectionId" v-model:open="sidebarVisibleDatabasesOpen" :connection-id="sidebarVisibleDatabasesTarget.connectionId" :connection-name="sidebarVisibleDatabasesTarget.label" />
<SidebarVisibleSchemasDialog
v-if="sidebarVisibleSchemasTarget?.connectionId && sidebarVisibleSchemasTarget.database != null"
v-model:open="sidebarVisibleSchemasOpen"
:connection-id="sidebarVisibleSchemasTarget.connectionId"
:connection-name="sidebarVisibleSchemasTarget.label"
:database="sidebarVisibleSchemasTarget.database"
/>
<SidebarDangerConfirmDialog
v-if="sidebarDangerDialogRequest"
v-model:open="sidebarDangerDialogOpen"
:title="sidebarDangerDialogRequest.title"
:message="sidebarDangerDialogRequest.message"
:sql="sidebarDangerDialogRequest.sql"
:details="sidebarDangerDialogRequest.details"
:details-text="sidebarDangerDialogRequest.detailsText"
:confirm-label="sidebarDangerDialogRequest.confirmLabel"
:loading="sidebarDangerDialogConfirming || sidebarDangerDialogRequest.loading"
:close-on-confirm="false"
@confirm="confirmSidebarDangerDialog"
>
<template v-if="sidebarDangerDialogRequest.option" #options>
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
<input :checked="sidebarDangerDialogRequest.option.checked" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="updateSidebarDangerDialogOption" />
<span class="grid gap-0.5">
<span class="font-medium text-foreground">{{ sidebarDangerDialogRequest.option.label }}</span>
<span class="text-xs leading-5 text-muted-foreground">{{ sidebarDangerDialogRequest.option.hint }}</span>
</span>
</label>
</template>
</SidebarDangerConfirmDialog>
<SidebarTreeItemDialogs v-if="sidebarTreeItemDialogController" :key="sidebarTreeItemDialogController.node?.id" :controller="sidebarTreeItemDialogController" @closed="sidebarTreeItemDialogController = null" />
<InstallExtensionDialog v-if="sidebarInstallExtensionTarget" ref="sidebarInstallExtensionDialogRef" :node="sidebarInstallExtensionTarget" @close="refreshSidebarActionTarget" />
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
{{ t("sidebar.noConnections") }}
</div>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { AlertTriangle } from "@lucide/vue";
defineProps<{ error?: unknown }>();
</script>
<template>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/20">
<div class="flex max-w-md items-start gap-2 rounded-md border border-destructive/40 bg-background p-4 text-sm text-destructive shadow-lg">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span class="break-all">{{ error instanceof Error ? error.message : String(error || "Dialog failed to load") }}</span>
</div>
</div>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { Loader2 } from "@lucide/vue";
</script>
<template>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/20" aria-busy="true">
<div class="rounded-md border border-border bg-background p-4 shadow-lg">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
</div>
</template>

View File

@ -0,0 +1,503 @@
<script setup lang="ts">
import { toRefs, watch } from "vue";
import { Loader2, Clipboard, Upload } from "@lucide/vue";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SearchableSelect } from "@/components/ui/searchable-select";
const props = defineProps<{ controller: Record<string, any> }>();
const emit = defineEmits<{ closed: [] }>();
const {
node,
t,
highlight,
showDeleteConfirm,
connectionDeleteConfirmMessage,
confirmDelete,
connectionDeleteMenuLabel,
showMoveToNewGroupDialog,
moveToNewGroupName,
confirmMoveToNewGroup,
showDeleteGroupConfirm,
confirmDeleteGroup,
showRenameObjectDialog,
renameObjectName,
renameObjectPreviewSql,
renameObjectError,
confirmRenameObject,
showStructurePreviewDialog,
structurePreviewTitle,
isLoadingStructurePreview,
structurePreviewError,
structurePreviewSql,
copyStructurePreview,
saveStructurePreview,
showStructureDocCopyDialog,
structureDocCopyTitle,
structureDocCopyText,
selectTextareaContent,
copyStructureDocText,
showDuplicateDialog,
duplicateTableName,
confirmDuplicateStructure,
showPasteDialog,
pasteTableEntries,
pasteTableMode,
pasteTableDataCopySupported,
confirmPasteTable,
showCreateDatabaseDialog,
createDatabaseName,
createDatabaseCharset,
createDatabaseCharsetOptions,
createDatabaseCharsetLoading,
normalizeCreateDatabaseCharset,
createDatabaseCollation,
createDatabaseCollationOptionsForCharset,
createDatabaseCollationsByCharset,
confirmCreateDatabase,
showEditDatabasePropertiesDialog,
editDatabasePropertiesLoading,
editDatabaseCharset,
editDatabaseCollation,
canEditDatabaseComment,
editDatabaseCommentText,
editDatabasePropertiesPreviewSql,
confirmEditDatabaseProperties,
showCreateNacosNamespaceDialog,
createNacosNamespaceId,
createNacosNamespaceName,
createNacosNamespaceDesc,
createNacosNamespaceLoading,
confirmCreateNacosNamespace,
showEditNacosNamespaceDialog,
editNacosNamespaceName,
editNacosNamespaceDesc,
editNacosNamespaceLoading,
confirmEditNacosNamespace,
showCreateSchemaDialog,
createSchemaName,
confirmCreateSchema,
showEditSchemaCommentDialog,
schemaCommentText,
schemaCommentLoading,
schemaCommentPreviewSql,
confirmEditSchemaComment,
canSetCreateDatabaseCharset,
updateCreateDatabaseCharset,
canEditDatabaseCharsetCollation,
updateEditDatabaseCharset,
} = toRefs(props.controller);
function pasteTargetsMissing(entries: Array<{ targetName: string }>): boolean {
return entries.every((entry) => !entry.targetName.trim());
}
watch(
[
showDeleteConfirm,
showMoveToNewGroupDialog,
showDeleteGroupConfirm,
showRenameObjectDialog,
showStructurePreviewDialog,
showStructureDocCopyDialog,
showDuplicateDialog,
showPasteDialog,
showCreateDatabaseDialog,
showEditDatabasePropertiesDialog,
showCreateNacosNamespaceDialog,
showEditNacosNamespaceDialog,
showCreateSchemaDialog,
showEditSchemaCommentDialog,
],
(open) => {
if (open.every((value) => !value)) emit("closed");
},
);
</script>
<template>
<Dialog v-model:open="showDeleteConfirm">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.confirmDeleteTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">
{{ connectionDeleteConfirmMessage() }}
</p>
<DialogFooter>
<Button variant="outline" @click="showDeleteConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button
variant="destructive"
@click="
showDeleteConfirm = false;
confirmDelete();
"
>{{ connectionDeleteMenuLabel() }}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showMoveToNewGroupDialog">
<DialogContent class="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle>{{ t("connectionGroup.createGroup") }}</DialogTitle>
</DialogHeader>
<Input v-model="moveToNewGroupName" :placeholder="t('connectionGroup.groupNamePlaceholder')" @keydown.enter.prevent="confirmMoveToNewGroup" />
<DialogFooter>
<Button variant="outline" @click="showMoveToNewGroupDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!moveToNewGroupName.trim()" @click="confirmMoveToNewGroup">{{ t("connectionGroup.createGroup") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteGroupConfirm">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("connectionGroup.deleteGroupConfirmTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">
{{ t("connectionGroup.deleteGroupConfirmMessage", { name: node.label }) }}
</p>
<DialogFooter>
<Button variant="outline" @click="showDeleteGroupConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" @click="confirmDeleteGroup">{{ t("connectionGroup.deleteGroup") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showRenameObjectDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<Input v-model="renameObjectName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRenameObject" />
<pre v-if="renameObjectPreviewSql" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renameObjectPreviewSql)"></pre>
<p v-if="renameObjectError" class="text-sm text-destructive">{{ renameObjectError }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="showRenameObjectDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!renameObjectName.trim() || renameObjectName.trim() === node.label" @click="confirmRenameObject">
{{ t("contextMenu.renameObject") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showStructurePreviewDialog">
<DialogContent class="sm:max-w-[760px]">
<DialogHeader>
<DialogTitle>{{ structurePreviewTitle || t("contextMenu.exportStructure") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div v-if="isLoadingStructurePreview" class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>{{ t("contextMenu.exportStructureLoading") }}</span>
</div>
<p v-else-if="structurePreviewError" class="text-sm text-destructive">{{ structurePreviewError }}</p>
<pre v-else class="max-h-[56vh] min-h-64 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(structurePreviewSql)"></pre>
</div>
<DialogFooter>
<Button variant="outline" @click="showStructurePreviewDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="outline" :disabled="isLoadingStructurePreview || !structurePreviewSql" @click="copyStructurePreview">
<Clipboard class="h-4 w-4" />
{{ t("contextMenu.copyStructure") }}
</Button>
<Button :disabled="isLoadingStructurePreview || !structurePreviewSql" @click="saveStructurePreview">
<Upload class="h-4 w-4" />
{{ t("contextMenu.saveStructure") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showStructureDocCopyDialog">
<DialogContent class="sm:max-w-[760px]">
<DialogHeader>
<DialogTitle>{{ structureDocCopyTitle || t("contextMenu.copyStructureAs") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<p class="text-sm text-muted-foreground">{{ t("contextMenu.structureDocCopyFallbackHint") }}</p>
<textarea readonly class="max-h-[56vh] min-h-64 resize-y overflow-auto rounded bg-muted p-3 font-mono text-xs whitespace-pre" :value="structureDocCopyText" @focus="selectTextareaContent"></textarea>
</div>
<DialogFooter>
<Button variant="outline" @click="showStructureDocCopyDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!structureDocCopyText" @click="copyStructureDocText">
<Clipboard class="h-4 w-4" />
{{ t("contextMenu.copyStructure") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showDuplicateDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.duplicateNameTitle") }}</DialogTitle>
</DialogHeader>
<Input v-model="duplicateTableName" :placeholder="t('contextMenu.duplicateNamePlaceholder')" @keydown.enter.prevent="confirmDuplicateStructure" />
<DialogFooter>
<Button variant="outline" @click="showDuplicateDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!duplicateTableName.trim()" @click="confirmDuplicateStructure">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showPasteDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle") }}</DialogTitle>
</DialogHeader>
<div class="space-y-4">
<div class="flex gap-2">
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
<input v-model="pasteTableMode" type="radio" value="structure-and-data" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
{{ t("contextMenu.pasteOptionStructureAndData") }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input v-model="pasteTableMode" type="radio" value="structure-only" class="accent-primary" />
{{ t("contextMenu.pasteOptionStructureOnly") }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
<input v-model="pasteTableMode" type="radio" value="data-only" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
{{ t("contextMenu.pasteOptionDataOnly") }}
</label>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div v-for="(entry, idx) in pasteTableEntries" :key="idx" class="flex items-center gap-2">
<span class="text-sm text-muted-foreground truncate min-w-0 flex-shrink basis-1/3" :title="entry.sourceName">{{ entry.sourceName }}</span>
<span class="text-xs text-muted-foreground flex-shrink-0">&rarr;</span>
<Input v-model="entry.targetName" class="flex-1 h-8 text-sm" :placeholder="t('contextMenu.duplicateNamePlaceholder')" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showPasteDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="pasteTargetsMissing(pasteTableEntries)" @click="confirmPasteTable">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateDatabaseDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.createDatabase") }}</DialogTitle>
</DialogHeader>
<Input v-model="createDatabaseName" :placeholder="t('contextMenu.createDatabaseNamePlaceholder')" @keydown.enter.prevent="confirmCreateDatabase" />
<div v-if="canSetCreateDatabaseCharset" class="grid gap-2">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.createDatabaseCharset") }}</label>
<SearchableSelect
:model-value="createDatabaseCharset"
:options="createDatabaseCharsetOptions"
:placeholder="t('contextMenu.createDatabaseCharsetPlaceholder')"
:search-placeholder="t('contextMenu.createDatabaseCharsetSearchPlaceholder')"
:empty-text="t('contextMenu.createDatabaseCharsetEmpty')"
:loading-text="t('contextMenu.createDatabaseCharsetLoading')"
:loading="createDatabaseCharsetLoading"
:normalize-custom="normalizeCreateDatabaseCharset"
allow-custom
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between border bg-background px-3 text-sm shadow-xs hover:bg-accent"
content-class="w-[var(--reka-popover-trigger-width)]"
@update:model-value="updateCreateDatabaseCharset"
>
<template #custom-option-label="{ value }">
<span class="truncate">{{ t("contextMenu.createDatabaseCharsetCustomOption", { value }) }}</span>
</template>
</SearchableSelect>
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.createDatabaseCollation") }}</label>
<SearchableSelect
v-model="createDatabaseCollation"
:options="createDatabaseCollationOptionsForCharset(createDatabaseCharset, createDatabaseCollationsByCharset)"
:placeholder="t('contextMenu.createDatabaseCollationPlaceholder')"
:search-placeholder="t('contextMenu.createDatabaseCollationSearchPlaceholder')"
:empty-text="t('contextMenu.createDatabaseCollationEmpty')"
:loading-text="t('contextMenu.createDatabaseCollationLoading')"
:loading="createDatabaseCharsetLoading"
:normalize-custom="normalizeCreateDatabaseCharset"
allow-custom
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between border bg-background px-3 text-sm shadow-xs hover:bg-accent"
content-class="w-[var(--reka-popover-trigger-width)]"
>
<template #custom-option-label="{ value }">
<span class="truncate">{{ t("contextMenu.createDatabaseCollationCustomOption", { value }) }}</span>
</template>
</SearchableSelect>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showCreateDatabaseDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!createDatabaseName.trim()" @click="confirmCreateDatabase">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showEditDatabasePropertiesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.editDatabasePropertiesTitle", { name: node.label }) }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div v-if="canEditDatabaseCharsetCollation" class="grid gap-3">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.createDatabaseCharset") }}</label>
<SearchableSelect
:model-value="editDatabaseCharset"
:options="createDatabaseCharsetOptions"
:placeholder="t('contextMenu.createDatabaseCharsetPlaceholder')"
:search-placeholder="t('contextMenu.createDatabaseCharsetSearchPlaceholder')"
:empty-text="t('contextMenu.createDatabaseCharsetEmpty')"
:loading-text="t('contextMenu.createDatabaseCharsetLoading')"
:loading="createDatabaseCharsetLoading"
:normalize-custom="normalizeCreateDatabaseCharset"
allow-custom
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between border bg-background px-3 text-sm shadow-xs hover:bg-accent"
content-class="w-[var(--reka-popover-trigger-width)]"
@update:model-value="updateEditDatabaseCharset"
>
<template #custom-option-label="{ value }">
<span class="truncate">{{ t("contextMenu.createDatabaseCharsetCustomOption", { value }) }}</span>
</template>
</SearchableSelect>
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.createDatabaseCollation") }}</label>
<SearchableSelect
v-model="editDatabaseCollation"
:options="createDatabaseCollationOptionsForCharset(editDatabaseCharset, createDatabaseCollationsByCharset)"
:placeholder="t('contextMenu.createDatabaseCollationPlaceholder')"
:search-placeholder="t('contextMenu.createDatabaseCollationSearchPlaceholder')"
:empty-text="t('contextMenu.createDatabaseCollationEmpty')"
:loading-text="t('contextMenu.createDatabaseCollationLoading')"
:loading="createDatabaseCharsetLoading"
:normalize-custom="normalizeCreateDatabaseCharset"
allow-custom
trigger-variant="outline"
trigger-class="h-9 w-full max-w-none justify-between border bg-background px-3 text-sm shadow-xs hover:bg-accent"
content-class="w-[var(--reka-popover-trigger-width)]"
>
<template #custom-option-label="{ value }">
<span class="truncate">{{ t("contextMenu.createDatabaseCollationCustomOption", { value }) }}</span>
</template>
</SearchableSelect>
</div>
</div>
<div v-if="canEditDatabaseComment" class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.editDatabaseComment") }}</label>
<textarea
v-model="editDatabaseCommentText"
class="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/40"
:placeholder="t('contextMenu.editDatabaseCommentPlaceholder')"
:disabled="editDatabasePropertiesLoading"
@keydown.meta.enter.prevent="confirmEditDatabaseProperties"
@keydown.ctrl.enter.prevent="confirmEditDatabaseProperties"
></textarea>
</div>
<pre v-if="editDatabasePropertiesPreviewSql" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(editDatabasePropertiesPreviewSql)"></pre>
</div>
<DialogFooter>
<Button variant="outline" :disabled="editDatabasePropertiesLoading" @click="showEditDatabasePropertiesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="editDatabasePropertiesLoading" @click="confirmEditDatabaseProperties">
{{ editDatabasePropertiesLoading ? t("contextMenu.editDatabasePropertiesSaving") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateNacosNamespaceDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("nacos.createNamespace") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceId") }}</label>
<Input v-model="createNacosNamespaceId" :placeholder="t('nacos.namespaceIdPlaceholder')" @keydown.enter.prevent="confirmCreateNacosNamespace" />
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceName") }}</label>
<Input v-model="createNacosNamespaceName" :placeholder="t('nacos.namespaceNamePlaceholder')" @keydown.enter.prevent="confirmCreateNacosNamespace" />
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceDesc") }}</label>
<Input v-model="createNacosNamespaceDesc" :placeholder="t('nacos.namespaceDescPlaceholder')" @keydown.enter.prevent="confirmCreateNacosNamespace" />
</div>
</div>
<DialogFooter>
<Button variant="outline" :disabled="createNacosNamespaceLoading" @click="showCreateNacosNamespaceDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!createNacosNamespaceName.trim() || createNacosNamespaceLoading" @click="confirmCreateNacosNamespace">
{{ createNacosNamespaceLoading ? t("nacos.creatingNamespace") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showEditNacosNamespaceDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("nacos.editNamespace") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceName") }}</label>
<Input v-model="editNacosNamespaceName" :placeholder="t('nacos.namespaceNamePlaceholder')" @keydown.enter.prevent="confirmEditNacosNamespace" />
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceDesc") }}</label>
<Input v-model="editNacosNamespaceDesc" :placeholder="t('nacos.namespaceDescPlaceholder')" @keydown.enter.prevent="confirmEditNacosNamespace" />
</div>
</div>
<DialogFooter>
<Button variant="outline" :disabled="editNacosNamespaceLoading" @click="showEditNacosNamespaceDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!editNacosNamespaceName.trim() || editNacosNamespaceLoading" @click="confirmEditNacosNamespace">
{{ editNacosNamespaceLoading ? t("nacos.updatingNamespace") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateSchemaDialog">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.createSchema") }}</DialogTitle>
</DialogHeader>
<Input v-model="createSchemaName" :placeholder="t('contextMenu.createSchemaNamePlaceholder')" @keydown.enter.prevent="confirmCreateSchema" />
<DialogFooter>
<Button variant="outline" @click="showCreateSchemaDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!createSchemaName.trim()" @click="confirmCreateSchema">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showEditSchemaCommentDialog">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.editSchemaCommentTitle", { name: node.label }) }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<textarea
v-model="schemaCommentText"
class="min-h-28 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/40"
:placeholder="t('contextMenu.schemaCommentPlaceholder')"
:disabled="schemaCommentLoading"
@keydown.meta.enter.prevent="confirmEditSchemaComment"
@keydown.ctrl.enter.prevent="confirmEditSchemaComment"
></textarea>
<pre v-if="schemaCommentPreviewSql" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(schemaCommentPreviewSql)"></pre>
</div>
<DialogFooter>
<Button variant="outline" :disabled="schemaCommentLoading" @click="showEditSchemaCommentDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="schemaCommentLoading" @click="confirmEditSchemaComment">
{{ schemaCommentLoading ? t("contextMenu.schemaCommentSaving") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
import { defineAsyncComponent, type Component } from "vue";
import SidebarAsyncDialogError from "./SidebarAsyncDialogError.vue";
import SidebarAsyncDialogLoading from "./SidebarAsyncDialogLoading.vue";
function lazySidebarDialog(loader: () => Promise<Component>) {
return defineAsyncComponent({
loader,
loadingComponent: SidebarAsyncDialogLoading,
errorComponent: SidebarAsyncDialogError,
delay: 120,
timeout: 15_000,
onError(_error, retry, fail, attempts) {
if (attempts < 2) retry();
else fail();
},
});
}
export const SidebarDangerConfirmDialog = lazySidebarDialog(() => import("@/components/editor/DangerConfirmDialog.vue"));
export const SidebarVisibleDatabasesDialog = lazySidebarDialog(() => import("@/components/sidebar/VisibleDatabasesDialog.vue"));
export const SidebarVisibleSchemasDialog = lazySidebarDialog(() => import("@/components/sidebar/VisibleSchemasDialog.vue"));
export const SidebarDdlViewDialog = lazySidebarDialog(() => import("@/components/objects/DdlViewDialog.vue"));
export const SidebarObjectSourceDialog = lazySidebarDialog(() => import("@/components/objects/ObjectSourceDialog.vue"));
export const SidebarProcedureExecutionDialog = lazySidebarDialog(() => import("@/components/objects/ProcedureExecutionDialog.vue"));

View File

@ -0,0 +1,139 @@
import { ref, shallowRef } from "vue";
import type { TreeNode } from "@/types/database";
import type { PasteTableMode } from "@/lib/table/tableClipboard";
import { fallbackCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
export type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
type ConnectionDeleteTarget = TreeNode & { connectionId: string };
export const fallbackCreateDatabaseCharset = fallbackCreateDatabaseCharsetMetadata();
export const sidebarTreeDialogOwner = shallowRef<symbol | null>(null);
export const sidebarDangerTarget = shallowRef<TreeNode | null>(null);
export const sidebarFormTarget = shallowRef<TreeNode | null>(null);
export const connectionDeleteTargetSnapshot = ref<ConnectionDeleteTarget[]>([]);
export const showDeleteConfirm = ref(false);
export const showDropTableConfirm = ref(false);
export const showDropTableChildObjectConfirm = ref(false);
export const showBatchDropConfirm = ref(false);
export const showBatchEmptyConfirm = ref(false);
export const showBatchTruncateConfirm = ref(false);
export const showStructurePreviewDialog = ref(false);
export const showStructureDocCopyDialog = ref(false);
export const structurePreviewSql = ref("");
export const structurePreviewTitle = ref("");
export const structurePreviewDefaultFileName = ref("structure.sql");
export const structurePreviewError = ref("");
export const structureDocCopyText = ref("");
export const structureDocCopyTitle = ref("");
export const isLoadingStructurePreview = ref(false);
export const showEmptyTableConfirm = ref(false);
export const showTruncateTableConfirm = ref(false);
export const showRenameObjectDialog = ref(false);
export const renameObjectName = ref("");
export const renameObjectError = ref("");
export const renameObjectPreviewSql = ref("");
export const dropTablePreviewSql = ref("");
export const dropTableCascade = ref(false);
export const batchDropCascade = ref(false);
export const emptyTablePreviewSql = ref("");
export const truncateTablePreviewSql = ref("");
export const truncateTableCascade = ref(false);
export const dropObjectPreviewSql = ref("");
export const showDropObjectConfirm = ref(false);
export const dropTableChildObjectPreviewSql = ref("");
export const batchDropPreviewSql = ref("");
export const batchEmptyPreviewSql = ref("");
export const batchEmptyTargets = ref<TreeNode[]>([]);
export const batchDropTargets = ref<TreeNode[]>([]);
export const batchTruncateTargets = ref<TreeNode[]>([]);
export const batchTruncatePreviewSql = ref("");
export const batchTruncateCascade = ref(false);
export const dropDatabasePreviewSql = ref("");
export const dropSchemaPreviewSql = ref("");
export const showDuplicateDialog = ref(false);
export const duplicateTableName = ref("");
export const duplicateStructureSource = ref<DuplicateStructureSource | null>(null);
export const showPasteDialog = ref(false);
export const pasteTableMode = ref<PasteTableMode>("structure-and-data");
export const pasteTableEntries = ref<Array<{ sourceName: string; targetName: string; connectionId: string; database: string; schema?: string }>>([]);
export const showCreateDatabaseDialog = ref(false);
export const createDatabaseName = ref("");
export const createDatabaseCharset = ref("utf8mb4");
export const createDatabaseCollation = ref("utf8mb4_unicode_ci");
export const showCreateNacosNamespaceDialog = ref(false);
export const createNacosNamespaceId = ref("");
export const createNacosNamespaceName = ref("");
export const createNacosNamespaceDesc = ref("");
export const createNacosNamespaceLoading = ref(false);
export const showEditNacosNamespaceDialog = ref(false);
export const editNacosNamespaceName = ref("");
export const editNacosNamespaceDesc = ref("");
export const editNacosNamespaceLoading = ref(false);
export const createDatabaseCharsetOptions = ref<string[]>(fallbackCreateDatabaseCharset.charsets);
export const createDatabaseCollationsByCharset = ref<Record<string, string[]>>(fallbackCreateDatabaseCharset.collationsByCharset);
export const createDatabaseCharsetLoading = ref(false);
export const showDropDatabaseConfirm = ref(false);
export const dropDatabaseLoading = ref(false);
export const showDropMongoCollectionConfirm = ref(false);
export const dropMongoCollectionLoading = ref(false);
export const showDropMongoIndexConfirm = ref(false);
export const dropMongoIndexLoading = ref(false);
export const showDropAllMongoIndexesConfirm = ref(false);
export const dropAllMongoIndexesLoading = ref(false);
export const showFlushRedisDbConfirm = ref(false);
export const showCreateSchemaDialog = ref(false);
export const createSchemaName = ref("");
export const showDropSchemaConfirm = ref(false);
export const showEditDatabasePropertiesDialog = ref(false);
export const editDatabasePropertiesLoading = ref(false);
export const editDatabasePropertiesPreviewSql = ref("");
export const editDatabaseCharset = ref("utf8mb4");
export const editDatabaseCollation = ref("utf8mb4_unicode_ci");
export const editDatabaseCommentText = ref("");
export const showEditSchemaCommentDialog = ref(false);
export const schemaCommentText = ref("");
export const schemaCommentLoading = ref(false);
export const schemaCommentPreviewSql = ref("");
export const showDeleteGroupConfirm = ref(false);
export const showMoveToNewGroupDialog = ref(false);
export const moveToNewGroupName = ref("");
const openFlags = [
showDeleteConfirm,
showDropTableConfirm,
showDropTableChildObjectConfirm,
showBatchDropConfirm,
showBatchEmptyConfirm,
showBatchTruncateConfirm,
showStructurePreviewDialog,
showStructureDocCopyDialog,
showEmptyTableConfirm,
showTruncateTableConfirm,
showDropObjectConfirm,
showRenameObjectDialog,
showDuplicateDialog,
showPasteDialog,
showCreateDatabaseDialog,
showCreateNacosNamespaceDialog,
showEditNacosNamespaceDialog,
showDropDatabaseConfirm,
showDropMongoCollectionConfirm,
showDropMongoIndexConfirm,
showDropAllMongoIndexesConfirm,
showFlushRedisDbConfirm,
showCreateSchemaDialog,
showDropSchemaConfirm,
showEditDatabasePropertiesDialog,
showEditSchemaCommentDialog,
showDeleteGroupConfirm,
showMoveToNewGroupDialog,
];
export function resetSidebarTreeDialogState() {
for (const flag of openFlags) flag.value = false;
sidebarTreeDialogOwner.value = null;
sidebarDangerTarget.value = null;
sidebarFormTarget.value = null;
connectionDeleteTargetSnapshot.value = [];
}

View File

@ -70,6 +70,8 @@ function close() {
show.value = false;
}
defineExpose({ close });
function onPointerDownOutside(e: PointerEvent) {
// Only respond to primary (left) button presses. This avoids a macOS
// issue where Ctrl+right-click generates a synthetic click event on
@ -125,9 +127,12 @@ function handleSubItemClick(item: ContextMenuItem) {
item.action?.();
}
function onContextMenu(event: MouseEvent) {
function onContextMenu(event: MouseEvent, itemsOverride?: ContextMenuItem[]) {
// Some callers build large context menus; resolve them only for actual opens.
const items = typeof props.items === "function" ? props.items() : props.items;
// Tree-level hosts may replace their items and open in the same event turn.
// Accepting the resolved items directly avoids reading the previous prop
// value before Vue has flushed the parent-to-child update.
const items = itemsOverride ?? (typeof props.items === "function" ? props.items() : props.items);
if (items.length === 0) return;
activeItems.value = items;
event.preventDefault();

View File

@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { createFlatTreeIndex, flattenTree, mutateFlatTreeExpansion, replaceFlatTreeChildren, type FlatTreeNode } from "@/composables/useFlatTree";
import type { TreeNode, TreeNodeType } from "@/types/database";
function item(id: string, type: TreeNodeType, depth: number, children?: TreeNode[]): FlatTreeNode {
const node: TreeNode = { id, label: id, type, children };
return { id, type, depth, node, poolType: type };
}
function createIndex(nodes: FlatTreeNode[]) {
const databaseTypes = new Set<TreeNodeType>(["database", "redis-db", "mongo-db"]);
return createFlatTreeIndex(nodes, {
isSelectable: (node) => node.type !== "table-search-control",
isBoundary: (type) => type === "connection" || type === "connection-group",
isDatabaseContainer: (type) => databaseTypes.has(type),
isSchemaContainer: (type) => type === "schema",
});
}
describe("createFlatTreeIndex", () => {
it("builds selection and lookup indexes in visible order", () => {
const nodes = [item("connection", "connection", 0, [{ id: "child", label: "child", type: "database" }]), item("database", "database", 1), item("search", "table-search-control", 2), item("table", "table", 2)];
const index = createIndex(nodes);
expect(index.visibleNodes.map((node) => node.id)).toEqual(["connection", "database", "search", "table"]);
expect(index.selectableVisibleNodes.map((node) => node.id)).toEqual(["connection", "database", "table"]);
expect(index.selectableVisibleNodeIndexById.get("table")).toBe(2);
expect(index.selectableVisibleNodeIndexById.has("search")).toBe(false);
expect(index.flatNodeIndexById.get("table")).toBe(3);
expect(index.nodeById.get("database")).toBe(nodes[1].node);
expect(index.expandableNodeIds).toEqual(["connection"]);
});
it("prefers database containers and falls back to schemas per connection", () => {
const nodes = [
item("connection-a", "connection", 0),
item("database-a", "database", 1),
item("schema-a", "schema", 2),
item("table-a", "table", 3),
item("schema-b", "schema", 2),
item("table-b", "table", 3),
item("database-b", "database", 1),
item("table-c", "table", 2),
item("connection-b", "connection", 0),
item("schema-c", "schema", 1),
item("table-d", "table", 2),
];
const index = createIndex(nodes);
expect([...index.stickyContainerIndexByIndex]).toEqual([-1, 1, 1, 1, 1, 1, 6, 6, -1, 9, 9]);
expect(index.nextDatabaseContainerIndexByIndex[1]).toBe(6);
expect(index.nextDatabaseContainerIndexByIndex[6]).toBe(-1);
expect(index.nextSchemaContainerIndexByIndex[2]).toBe(4);
expect(index.nextSchemaContainerIndexByIndex[4]).toBe(-1);
expect(index.nextSchemaContainerIndexByIndex[9]).toBe(-1);
});
it("keeps indexes isolated across connections without database containers", () => {
const nodes = [item("connection-a", "connection", 0), item("schema-a", "schema", 1), item("table-a", "table", 2), item("connection-b", "connection", 0), item("table-b", "table", 1)];
const index = createIndex(nodes);
expect(index.stickyContainerIndexByIndex[2]).toBe(1);
expect(index.stickyContainerIndexByIndex[4]).toBe(-1);
expect(index.nextSchemaContainerIndexByIndex[1]).toBe(-1);
expect(index.flatNodeIndexById.get("table-b")).toBe(4);
});
it("indexes a synthetic large visible tree in one pass", () => {
const nodes: FlatTreeNode[] = [item("connection", "connection", 0)];
for (let index = 0; index < 20_000; index += 1) {
nodes.push(item(`table-${index}`, "table", 1));
}
const index = createIndex(nodes);
expect(index.visibleNodes).toHaveLength(20_001);
expect(index.flatNodeIndexById.get("table-19999")).toBe(20_000);
expect(index.stickyContainerIndexByIndex).toHaveLength(20_001);
});
});
describe("flat-tree range mutations", () => {
it("expands and collapses only the affected descendant range", () => {
const table: TreeNode = { id: "table", label: "table", type: "table" };
const schema: TreeNode = { id: "schema", label: "schema", type: "schema", children: [table] };
const database: TreeNode = { id: "database", label: "database", type: "database", children: [schema] };
const connection: TreeNode = { id: "connection", label: "connection", type: "connection", isExpanded: true, children: [database] };
const sibling: TreeNode = { id: "sibling", label: "sibling", type: "connection" };
const initial = flattenTree([connection, sibling]);
const expandedDatabase = mutateFlatTreeExpansion(initial, 1, database, true);
expect(expandedDatabase.map((entry) => entry.id)).toEqual(["connection", "database", "schema", "sibling"]);
expect(expandedDatabase.at(-1)?.node).toBe(sibling);
const expandedSchema = mutateFlatTreeExpansion(expandedDatabase, 2, schema, true);
expect(expandedSchema.map((entry) => entry.id)).toEqual(["connection", "database", "schema", "table", "sibling"]);
const collapsedDatabase = mutateFlatTreeExpansion(expandedSchema, 1, database, false);
expect(collapsedDatabase.map((entry) => entry.id)).toEqual(["connection", "database", "sibling"]);
expect(collapsedDatabase[2].node).toBe(sibling);
});
it("atomically replaces refreshed children and preserves surrounding rows", () => {
const oldTable: TreeNode = { id: "old-table", label: "old-table", type: "table" };
const database: TreeNode = { id: "database", label: "database", type: "database", isExpanded: true, children: [oldTable] };
const connection: TreeNode = { id: "connection", label: "connection", type: "connection", isExpanded: true, children: [database] };
const sibling: TreeNode = { id: "sibling", label: "sibling", type: "connection" };
const initial = flattenTree([connection, sibling]);
const newTable: TreeNode = { id: "new-table", label: "new-table", type: "table" };
database.children = [newTable];
const refreshed = replaceFlatTreeChildren(initial, 1, database);
expect(refreshed.map((entry) => entry.id)).toEqual(["connection", "database", "new-table", "sibling"]);
expect(refreshed[0]).toBe(initial[0]);
expect(refreshed[3]).toBe(initial[3]);
});
it("falls back to an unchanged copy when the indexed parent no longer matches", () => {
const nodes = [item("connection", "connection", 0)];
const other: TreeNode = { id: "other", label: "other", type: "connection", isExpanded: true, children: [] };
const result = replaceFlatTreeChildren(nodes, 0, other);
expect(result).toEqual(nodes);
expect(result).not.toBe(nodes);
});
});

View File

@ -12,6 +12,25 @@ export interface FlatTreeNode {
poolType: string;
}
export interface FlatTreeIndex {
visibleNodes: TreeNode[];
selectableVisibleNodes: TreeNode[];
selectableVisibleNodeIndexById: Map<string, number>;
flatNodeIndexById: Map<string, number>;
nodeById: Map<string, TreeNode>;
expandableNodeIds: string[];
stickyContainerIndexByIndex: Int32Array;
nextDatabaseContainerIndexByIndex: Int32Array;
nextSchemaContainerIndexByIndex: Int32Array;
}
interface FlatTreeIndexOptions {
isSelectable: (node: TreeNode) => boolean;
isBoundary: (type: TreeNodeType) => boolean;
isDatabaseContainer: (type: TreeNodeType) => boolean;
isSchemaContainer: (type: TreeNodeType) => boolean;
}
function walk(children: TreeNode[], depth: number, result: FlatTreeNode[]) {
for (const node of children) {
result.push({
@ -27,12 +46,113 @@ function walk(children: TreeNode[], depth: number, result: FlatTreeNode[]) {
}
}
function flatTreeNode(node: TreeNode, depth: number): FlatTreeNode {
return {
node,
depth,
id: node.id,
type: node.type,
poolType: node.type === "connection-group" ? `${node.type}:${node.id}` : node.type,
};
}
function visibleDescendantEnd(nodes: readonly FlatTreeNode[], parentIndex: number): number {
const parentDepth = nodes[parentIndex]?.depth;
if (parentDepth == null) return parentIndex;
let end = parentIndex + 1;
while (end < nodes.length && nodes[end].depth > parentDepth) end += 1;
return end;
}
export function flattenTree(nodes: TreeNode[]): FlatTreeNode[] {
const result: FlatTreeNode[] = [];
walk(nodes, 0, result);
return result;
}
export function replaceFlatTreeChildren(nodes: readonly FlatTreeNode[], parentIndex: number, parent: TreeNode): FlatTreeNode[] {
if (parentIndex < 0 || parentIndex >= nodes.length || nodes[parentIndex].id !== parent.id) return [...nodes];
const end = visibleDescendantEnd(nodes, parentIndex);
const replacement: FlatTreeNode[] = [];
if (parent.isExpanded && parent.children) walk(parent.children, nodes[parentIndex].depth + 1, replacement);
// One splice-shaped replacement keeps recycled-list consumers from observing
// an intermediate state where old and new child ranges coexist.
return [...nodes.slice(0, parentIndex), flatTreeNode(parent, nodes[parentIndex].depth), ...replacement, ...nodes.slice(end)];
}
export function mutateFlatTreeExpansion(nodes: readonly FlatTreeNode[], parentIndex: number, parent: TreeNode, expanded: boolean): FlatTreeNode[] {
if (parent.isExpanded !== expanded) parent.isExpanded = expanded;
return replaceFlatTreeChildren(nodes, parentIndex, parent);
}
export function shouldVirtualizeFlatTree(count: number): boolean {
return count > 0;
}
export function createFlatTreeIndex(nodes: readonly FlatTreeNode[], options: FlatTreeIndexOptions): FlatTreeIndex {
const visibleNodes: TreeNode[] = [];
const selectableVisibleNodes: TreeNode[] = [];
const selectableVisibleNodeIndexById = new Map<string, number>();
const flatNodeIndexById = new Map<string, number>();
const nodeById = new Map<string, TreeNode>();
const expandableNodeIds: string[] = [];
const stickyContainerIndexByIndex = new Int32Array(nodes.length);
const nextDatabaseContainerIndexByIndex = new Int32Array(nodes.length);
const nextSchemaContainerIndexByIndex = new Int32Array(nodes.length);
stickyContainerIndexByIndex.fill(-1);
nextDatabaseContainerIndexByIndex.fill(-1);
nextSchemaContainerIndexByIndex.fill(-1);
let databaseContainerIndex = -1;
let schemaContainerIndex = -1;
for (let index = 0; index < nodes.length; index += 1) {
const item = nodes[index];
const node = item.node;
visibleNodes.push(node);
flatNodeIndexById.set(item.id, index);
nodeById.set(item.id, node);
if (node.children?.length) expandableNodeIds.push(item.id);
if (options.isSelectable(node)) {
selectableVisibleNodeIndexById.set(item.id, selectableVisibleNodes.length);
selectableVisibleNodes.push(node);
}
if (options.isBoundary(item.type)) {
databaseContainerIndex = -1;
schemaContainerIndex = -1;
continue;
}
if (options.isDatabaseContainer(item.type)) databaseContainerIndex = index;
if (options.isSchemaContainer(item.type)) schemaContainerIndex = index;
stickyContainerIndexByIndex[index] = databaseContainerIndex >= 0 ? databaseContainerIndex : schemaContainerIndex;
}
let nextDatabaseContainerIndex = -1;
let nextSchemaContainerIndex = -1;
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const item = nodes[index];
if (options.isBoundary(item.type)) {
nextDatabaseContainerIndex = -1;
nextSchemaContainerIndex = -1;
continue;
}
nextDatabaseContainerIndexByIndex[index] = nextDatabaseContainerIndex;
nextSchemaContainerIndexByIndex[index] = nextSchemaContainerIndex;
if (options.isDatabaseContainer(item.type)) nextDatabaseContainerIndex = index;
if (options.isSchemaContainer(item.type)) nextSchemaContainerIndex = index;
}
return {
visibleNodes,
selectableVisibleNodes,
selectableVisibleNodeIndexById,
flatNodeIndexById,
nodeById,
expandableNodeIds,
stickyContainerIndexByIndex,
nextDatabaseContainerIndexByIndex,
nextSchemaContainerIndexByIndex,
};
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import { migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import { buildTreeNodesFromLayout } from "@/lib/sidebar/sidebarLayout";
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
@ -17,7 +17,7 @@ describe("sidebar pinned tree nodes", () => {
},
];
expect(updatePinnedTreeNodeInPlace(tree, "conn:db:b", true)).toBe("siblings");
expect(updatePinnedTreeNodeInPlace(tree, tree[0].children![1], true)).toBe("siblings");
expect(tree[0].children?.map((node) => node.id)).toEqual(["conn:db:b", "conn:db:a"]);
expect(tree[0].children?.[0].pinned).toBe(true);
@ -29,12 +29,57 @@ describe("sidebar pinned tree nodes", () => {
{ id: "group-b", label: "B", type: "connection-group" },
];
expect(updatePinnedTreeNodeInPlace(tree, "group-b", true)).toBe("root");
expect(updatePinnedTreeNodeInPlace(tree, tree[1], true)).toBe("root");
expect(tree.map((node) => node.id)).toEqual(["group-b", "group-a"]);
expect(tree[0].pinned).toBe(true);
});
it("scopes duplicate node ids by database when pinning", () => {
const databaseA: TreeNode = {
id: "conn:a",
label: "A",
type: "database",
children: [{ id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a" }],
};
const databaseB: TreeNode = {
id: "conn:b",
label: "B",
type: "database",
children: [{ id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b" }],
};
const tree: TreeNode[] = [{ id: "conn", label: "Connection", type: "connection", children: [databaseA, databaseB] }];
expect(updatePinnedTreeNodeInPlace(tree, databaseA.children![0], true)).toBe("siblings");
expect(databaseA.children![0].pinned).toBe(true);
expect(databaseB.children![0].pinned).not.toBe(true);
});
it("clears stale legacy duplicate pins after switching to scoped keys", () => {
const tableA: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a", pinned: true };
const tableB: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b", pinned: true };
const tree: TreeNode[] = [
{ id: "conn:a", label: "A", type: "database", children: [tableA] },
{ id: "conn:b", label: "B", type: "database", children: [tableB] },
];
syncPinnedTreeNodeStateInPlace(tree, new Set([treeNodePinKey(tableA)]));
expect(tableA.pinned).toBe(true);
expect(tableB.pinned).toBe(false);
});
it("migrates a legacy id once instead of pinning every colliding node", () => {
const tableA: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a" };
const tableB: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b" };
const migrated = migrateLegacyPinnedTreeNodeIds([tableA, tableB], new Set(["duplicate-table-id"]));
expect(migrated.changed).toBe(true);
expect(migrated.ids).toEqual(new Set([treeNodePinKey(tableA)]));
});
it("applies pinned state to connection groups when rebuilding from layout", () => {
const layout: SidebarLayout = {
groups: [

View File

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { createSidebarActionRequest, createSidebarActionTarget, findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
function tableNode(): TreeNode {
return {
id: "table-1",
label: "users",
type: "table",
connectionId: "connection-1",
database: "app",
schema: "public",
children: [{ id: "column-1", label: "id", type: "column" }],
};
}
describe("sidebar action targets", () => {
it("captures a frozen shallow snapshot without retaining children", () => {
const node = tableNode();
const target = createSidebarActionTarget(node);
node.label = "recycled-row";
node.connectionId = "connection-2";
expect(target).toMatchObject({ label: "users", connectionId: "connection-1", children: undefined, hiddenChildren: undefined });
expect(Object.isFrozen(target)).toBe(true);
});
it("captures selection independently from later selection changes", () => {
const selectedNodeIds = ["table-1", "table-2"];
const request = createSidebarActionRequest(tableNode(), selectedNodeIds, { initialEditing: true });
selectedNodeIds.splice(0, selectedNodeIds.length, "table-3");
expect(request.selectedNodeIds).toEqual(["table-1", "table-2"]);
expect(request.payload).toEqual({ initialEditing: true });
expect(Object.isFrozen(request.selectedNodeIds)).toBe(true);
expect(Object.isFrozen(request)).toBe(true);
});
it("does not resolve a recycled row with the same local id", () => {
const target = createSidebarActionTarget(tableNode());
const recycled = tableNode();
recycled.connectionId = "connection-2";
expect(findSidebarActionTarget([recycled], target)).toBeNull();
});
it("returns null when the action target was removed", () => {
const target = createSidebarActionTarget(tableNode());
expect(findSidebarActionTarget([], target)).toBeNull();
});
it("resolves targets stored only in hidden children", () => {
const node = tableNode();
const target = createSidebarActionTarget(node);
const root: TreeNode = { id: "root", label: "root", type: "database", hiddenChildren: [node] };
expect(findSidebarActionTarget([root], target)).toBe(node);
});
});

View File

@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately } from "@/lib/sidebar/sidebarDataOpenCoordinator";
describe("sidebarDataOpenCoordinator", () => {
afterEach(() => {
cancelPendingSidebarDataOpen();
});
it("cancels the active request when a newer open starts", async () => {
const cancel = vi.fn();
let firstIsCurrent = true;
runSidebarDataOpenImmediately(async (request) => {
request.registerCancel(cancel);
await Promise.resolve();
firstIsCurrent = request.isCurrent();
});
runSidebarDataOpenImmediately(() => undefined);
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
expect(firstIsCurrent).toBe(false);
});
it("runs registered cancellation immediately for an already stale request", async () => {
const cancel = vi.fn();
let registerLateCancel: (() => void) | undefined;
runSidebarDataOpenImmediately((request) => {
registerLateCancel = () => request.registerCancel(cancel);
});
runSidebarDataOpenImmediately(() => undefined);
registerLateCancel?.();
await Promise.resolve();
expect(cancel).toHaveBeenCalledOnce();
});
it("recovers after a runner throws synchronously", async () => {
const nextRunner = vi.fn();
runSidebarDataOpenImmediately(() => {
throw new Error("boom");
});
await Promise.resolve();
runSidebarDataOpenImmediately(nextRunner);
await Promise.resolve();
expect(nextRunner).toHaveBeenCalledOnce();
});
it("keeps only the newest rapid navigation request current", async () => {
const states: boolean[] = [];
const captures: Array<() => void> = [];
for (let index = 0; index < 3; index += 1) {
runSidebarDataOpenImmediately((request) => {
captures.push(() => states.push(request.isCurrent()));
});
}
captures.forEach((capture) => capture());
expect(states).toEqual([false, false, true]);
});
it("does not cancel work that was not registered as sidebar-owned", async () => {
const sidebarCancel = vi.fn();
const unrelatedEditorCancel = vi.fn();
runSidebarDataOpenImmediately((request) => request.registerCancel(sidebarCancel));
runSidebarDataOpenImmediately(() => undefined);
await Promise.resolve();
expect(sidebarCancel).toHaveBeenCalledOnce();
expect(unrelatedEditorCancel).not.toHaveBeenCalled();
});
it("prevents a superseded connection completion from becoming current", async () => {
let finishConnection: (() => void) | undefined;
let completionWasCurrent = true;
runSidebarDataOpenImmediately(async (request) => {
await new Promise<void>((resolve) => {
finishConnection = resolve;
});
completionWasCurrent = request.isCurrent();
});
runSidebarDataOpenImmediately(() => undefined);
finishConnection?.();
await Promise.resolve();
await Promise.resolve();
expect(completionWasCurrent).toBe(false);
});
});

View File

@ -2,6 +2,33 @@ import type { TreeNode } from "@/types/database";
export type PinnedTreeNodeUpdateScope = "missing" | "root" | "siblings";
export function treeNodePinKey(node: TreeNode): string {
if (!node.connectionId) return node.id;
const identity = [node.database || "", node.schema || "", node.catalog || "", node.type, node.objectName || node.tableName || node.label, node.signature || "", node.id];
return `${node.connectionId}:pin:v2:${encodeURIComponent(JSON.stringify(identity))}`;
}
export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinnedIds: Set<string>): { ids: Set<string>; changed: boolean } {
const next = new Set(pinnedIds);
let changed = false;
const visit = (items: readonly TreeNode[]) => {
for (const node of items) {
const pinKey = treeNodePinKey(node);
if (pinKey !== node.id && next.has(node.id) && !next.has(pinKey)) {
// A legacy id cannot distinguish colliding nodes. Claim it once for the
// first matching loaded node, then persist the fully scoped identity.
next.delete(node.id);
next.add(pinKey);
changed = true;
}
if (node.children) visit(node.children);
if (node.hiddenChildren) visit(node.hiddenChildren);
}
};
visit(nodes);
return { ids: next, changed };
}
export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
const pinned: T[] = [];
const unpinned: T[] = [];
@ -14,19 +41,20 @@ export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean):
return [...pinned, ...unpinned];
}
function findTreeNodeLocation(nodes: TreeNode[], id: string, parent: TreeNode | null = null): { node: TreeNode; parent: TreeNode | null } | null {
function findTreeNodeLocation(nodes: TreeNode[], target: TreeNode, parent: TreeNode | null = null): { node: TreeNode; parent: TreeNode | null } | null {
const targetKey = treeNodePinKey(target);
for (const node of nodes) {
if (node.id === id) return { node, parent };
if (node === target || treeNodePinKey(node) === targetKey) return { node, parent };
if (node.children) {
const found = findTreeNodeLocation(node.children, id, node);
const found = findTreeNodeLocation(node.children, target, node);
if (found) return found;
}
}
return null;
}
export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], id: string, pinned: boolean): PinnedTreeNodeUpdateScope {
const location = findTreeNodeLocation(nodes, id);
export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], target: TreeNode, pinned: boolean): PinnedTreeNodeUpdateScope {
const location = findTreeNodeLocation(nodes, target);
if (!location) return "missing";
location.node.pinned = pinned;
@ -42,13 +70,47 @@ export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], id: string, pinne
return "root";
}
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>): TreeNode[] {
function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode {
const existing = clones.get(node);
if (existing) return existing;
const clone: TreeNode = {
...node,
pinned: pinnedIds.has(treeNodePinKey(node)) || pinnedIds.has(node.id),
};
clones.set(node, clone);
if (node.children) clone.children = applyPinnedTreeNodeStateInternal(node.children, pinnedIds, clones);
if (node.hiddenChildren) clone.hiddenChildren = applyPinnedTreeNodeStateInternal(node.hiddenChildren, pinnedIds, clones);
return clone;
}
function applyPinnedTreeNodeStateInternal(nodes: TreeNode[], pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode[] {
return orderPinnedFirst(
nodes.map((node) => ({
...node,
pinned: pinnedIds.has(node.id),
children: node.children ? applyPinnedTreeNodeState(node.children, pinnedIds) : node.children,
})),
nodes.map((node) => clonePinnedTreeNode(node, pinnedIds, clones)),
(node) => !!node.pinned,
);
}
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>): TreeNode[] {
return applyPinnedTreeNodeStateInternal(nodes, pinnedIds, new WeakMap());
}
function syncPinnedTreeNodeStateInPlaceInternal(nodes: TreeNode[], pinnedIds: Set<string>, visited: WeakSet<TreeNode>): void {
for (const node of nodes) {
if (visited.has(node)) continue;
visited.add(node);
node.pinned = pinnedIds.has(treeNodePinKey(node)) || pinnedIds.has(node.id);
if (node.children) {
syncPinnedTreeNodeStateInPlaceInternal(node.children, pinnedIds, visited);
node.children = orderPinnedFirst(node.children, (child) => !!child.pinned);
}
if (node.hiddenChildren) {
syncPinnedTreeNodeStateInPlaceInternal(node.hiddenChildren, pinnedIds, visited);
node.hiddenChildren = orderPinnedFirst(node.hiddenChildren, (child) => !!child.pinned);
}
}
nodes.splice(0, nodes.length, ...orderPinnedFirst(nodes, (node) => !!node.pinned));
}
export function syncPinnedTreeNodeStateInPlace(nodes: TreeNode[], pinnedIds: Set<string>): void {
syncPinnedTreeNodeStateInPlaceInternal(nodes, pinnedIds, new WeakSet());
}

View File

@ -0,0 +1,45 @@
import type { TreeNode } from "@/types/database";
export type SidebarActionTarget = Readonly<TreeNode>;
export interface SidebarActionRequest<TPayload = undefined> {
target: SidebarActionTarget;
selectedNodeIds: readonly string[];
payload: TPayload;
}
function sameActionTarget(left: TreeNode, right: SidebarActionTarget): boolean {
return left.id === right.id && left.type === right.type && left.connectionId === right.connectionId && left.database === right.database && left.schema === right.schema && left.catalog === right.catalog && left.label === right.label && left.signature === right.signature;
}
export function findSidebarActionTarget(nodes: readonly TreeNode[], target: SidebarActionTarget): TreeNode | null {
const visited = new WeakSet<TreeNode>();
const find = (items: readonly TreeNode[]): TreeNode | null => {
for (const node of items) {
if (visited.has(node)) continue;
visited.add(node);
if (sameActionTarget(node, target)) return node;
const child = node.children ? find(node.children) : null;
if (child) return child;
const hiddenChild = node.hiddenChildren ? find(node.hiddenChildren) : null;
if (hiddenChild) return hiddenChild;
}
return null;
};
return find(nodes);
}
export function createSidebarActionTarget(node: TreeNode): SidebarActionTarget {
// Virtual rows can be recycled while an async dialog is opening, so actions
// must never retain the mutable row node or its potentially large child tree.
const meta = node.meta && typeof node.meta === "object" ? Object.freeze({ ...node.meta }) : node.meta;
return Object.freeze({ ...node, children: undefined, hiddenChildren: undefined, meta });
}
export function createSidebarActionRequest<TPayload>(node: TreeNode, selectedNodeIds: readonly string[], payload: TPayload): SidebarActionRequest<TPayload> {
return Object.freeze({
target: createSidebarActionTarget(node),
selectedNodeIds: Object.freeze([...selectedNodeIds]),
payload,
});
}

View File

@ -0,0 +1,22 @@
import type { SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
export interface SidebarDangerDialogOption {
checked: boolean;
label: string;
hint: string;
onChange?: (checked: boolean) => void | Promise<void>;
}
export interface SidebarDangerDialogRequest {
target: SidebarActionTarget;
title: string;
message: string;
confirmLabel: string;
sql?: string;
details?: string;
detailsText?: string;
loading?: boolean;
closeOnConfirm?: boolean;
option?: SidebarDangerDialogOption;
confirm: () => void | Promise<void>;
}

View File

@ -0,0 +1,60 @@
export interface SidebarDataOpenRequest {
isCurrent: () => boolean;
registerCancel: (cancel: () => void | Promise<void>) => void;
}
type OpenDataRunner = (request: SidebarDataOpenRequest) => void | Promise<void>;
let generation = 0;
let activeGeneration = 0;
let activeCancel: (() => void | Promise<void>) | null = null;
function runCancellation(cancel: (() => void | Promise<void>) | null) {
if (!cancel) return;
void Promise.resolve(cancel()).catch(() => undefined);
}
function supersedeCurrentRequest(): number {
generation += 1;
runCancellation(activeCancel);
activeCancel = null;
activeGeneration = 0;
return generation;
}
function executeRequest(requestGeneration: number, runner: OpenDataRunner) {
if (requestGeneration !== generation) return;
const request: SidebarDataOpenRequest = {
isCurrent: () => requestGeneration === generation,
registerCancel: (cancel) => {
if (requestGeneration !== generation) {
runCancellation(cancel);
return;
}
activeGeneration = requestGeneration;
activeCancel = cancel;
},
};
let result: void | Promise<void>;
try {
result = runner(request);
} catch {
result = undefined;
}
void Promise.resolve(result)
.catch(() => undefined)
.finally(() => {
if (activeGeneration !== requestGeneration) return;
activeGeneration = 0;
activeCancel = null;
});
}
export function runSidebarDataOpenImmediately(runner: OpenDataRunner) {
const requestGeneration = supersedeCurrentRequest();
executeRequest(requestGeneration, runner);
}
export function cancelPendingSidebarDataOpen() {
supersedeCurrentRequest();
}

View File

@ -4,6 +4,6 @@ export function isSidebarDatabaseOpened(node: TreeNode, isTreeNodeChildrenLoaded
return (node.type === "database" || node.type === "mongo-db" || node.type === "vector-database") && !!node.connectionId && node.database != null && isTreeNodeChildrenLoaded(node.id);
}
export function canCloseSidebarDatabaseConnection(node: TreeNode, isTreeNodeChildrenLoaded: (nodeId: string) => boolean): boolean {
return node.type === "database" && !!node.connectionId && node.database != null && isTreeNodeChildrenLoaded(node.id);
export function canCloseSidebarDatabaseConnection(node: TreeNode, isTreeNodeChildrenLoaded: (nodeId: string) => boolean, isDatabaseUsedByOpenTab: (connectionId: string, database: string) => boolean = () => false): boolean {
return node.type === "database" && !!node.connectionId && node.database != null && (isTreeNodeChildrenLoaded(node.id) || isDatabaseUsedByOpenTab(node.connectionId, node.database));
}

View File

@ -4,8 +4,8 @@ function isErrorResult(result: QueryResult | undefined): boolean {
return result?.columns.length === 1 && result.columns[0] === "Error";
}
export function canActivateExistingDataTableTab(tab: QueryTab): boolean {
if (tab.isExecuting) return true;
export function canActivateExistingDataTableTab(tab: QueryTab, options: { activateExecuting?: boolean } = {}): boolean {
if (tab.isExecuting) return options.activateExecuting !== false;
if (isErrorResult(tab.result)) return false;
return !!tab.result || !!tab.results?.length;
}

View File

@ -2,7 +2,7 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { ref, computed, watch, markRaw } from "vue";
import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database";
import { applyPinnedTreeNodeState, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
import { applyPinnedTreeNodeState, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
import {
reconcileLayout,
buildTreeNodesFromLayout,
@ -912,8 +912,9 @@ export const useConnectionStore = defineStore("connection", () => {
localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify([...pinnedTreeNodeIds.value]));
}
function isTreeNodePinned(id: string): boolean {
return pinnedTreeNodeIds.value.has(id);
function isTreeNodePinned(node: TreeNode | string): boolean {
if (typeof node === "string") return pinnedTreeNodeIds.value.has(node);
return pinnedTreeNodeIds.value.has(treeNodePinKey(node)) || pinnedTreeNodeIds.value.has(node.id);
}
function isConnectionUtilityNode(node: TreeNode): boolean {
@ -972,7 +973,12 @@ export const useConnectionStore = defineStore("connection", () => {
return child;
});
}
parent.children = markRawLeafTreeNodes(applyPinnedTreeNodeState(children, pinnedTreeNodeIds.value));
const migratedPins = migrateLegacyPinnedTreeNodeIds(children, pinnedTreeNodeIds.value);
if (migratedPins.changed) {
pinnedTreeNodeIds.value = migratedPins.ids;
persistPinnedTreeNodeIds();
}
parent.children = markRawLeafTreeNodes(applyPinnedTreeNodeState(children, migratedPins.ids));
loadedTreeNodeChildrenIds.value.add(parent.id);
}
@ -1584,15 +1590,21 @@ export const useConnectionStore = defineStore("connection", () => {
return null;
}
function toggleTreeNodePin(id: string) {
function toggleTreeNodePin(node: TreeNode) {
const pinKey = treeNodePinKey(node);
const next = new Set(pinnedTreeNodeIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
const wasPinned = next.has(pinKey) || next.has(node.id);
// Remove the legacy bare id as part of every toggle so old ambiguous pins
// cannot continue matching objects in a different database.
next.delete(node.id);
if (wasPinned) next.delete(pinKey);
else next.add(pinKey);
pinnedTreeNodeIds.value = next;
persistPinnedTreeNodeIds();
const scope = updatePinnedTreeNodeInPlace(treeNodes.value, id, next.has(id));
if (scope === "root") rebuildTreeNodes();
// Pinning is infrequent; synchronizing the loaded tree here also clears any
// stale flags created by legacy unscoped ids without rebuilding metadata.
syncPinnedTreeNodeStateInPlace(treeNodes.value, next);
}
async function addConnection(config: ConnectionConfig, targetGroupId?: string | null) {

View File

@ -85,10 +85,12 @@ test("keeps MySQL-compatible double-dash whitespace rules", () => {
test("propagates database type to every DDL viewer entrypoint", () => {
const ddlViewDialog = readFileSync("apps/desktop/src/components/objects/DdlViewDialog.vue", "utf8");
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
const app = readFileSync("apps/desktop/src/App.vue", "utf8");
assert.match(ddlViewDialog, /createDbxCodeMirrorSqlDialect\(langSql, props\.dialect, props\.databaseType\)/);
assert.match(treeItem, /<DdlViewDialog[\s\S]*?:database-type="ddlDatabaseType"[\s\S]*?v-model:open="showDdlDialog"/);
assert.match(connectionTree, /<SidebarDdlViewDialog/);
assert.match(connectionTree, /:database-type="sidebarDdlDatabaseType"/);
assert.match(connectionTree, /v-model:open="sidebarDdlOpen"/);
assert.match(app, /<DdlViewDialog[^>]*:database-type="queryEditorDdlDatabaseType"[^>]*\/>/);
});

View File

@ -22,6 +22,10 @@ test("activates an existing data table tab while it is still loading", () => {
assert.equal(canActivateExistingDataTableTab(dataTab({ isExecuting: true })), true);
});
test("sidebar can restart a loading tab after superseding its request", () => {
assert.equal(canActivateExistingDataTableTab(dataTab({ isExecuting: true }), { activateExecuting: false }), false);
});
test("activates an existing data table tab with a usable result", () => {
assert.equal(
canActivateExistingDataTableTab(

View File

@ -0,0 +1,38 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
test("tree-level context menu opens with the current row items atomically", () => {
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
const contextMenu = readFileSync("apps/desktop/src/components/ui/CustomContextMenu.vue", "utf8");
assert.match(connectionTree, /openContextMenu\(event, items\)/);
assert.match(connectionTree, /sidebarContextMenuRef\.value\?\.close\(\)/);
assert.match(connectionTree, /sidebarContextMenuTarget\.value = createSidebarActionTarget\(node\)/);
assert.match(connectionTree, /sidebarContextMenuTarget\.value = null/);
assert.match(connectionTree, /<CustomContextMenu ref="sidebarContextMenuRef"/);
assert.match(contextMenu, /function onContextMenu\(event: MouseEvent, itemsOverride\?: ContextMenuItem\[\]\)/);
assert.match(contextMenu, /const items = itemsOverride \?\?/);
assert.match(contextMenu, /defineExpose\(\{ close \}\)/);
});
test("rare sidebar dialogs share module-level async wrappers with fallbacks", () => {
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
const asyncDialogs = readFileSync("apps/desktop/src/components/sidebar/sidebarAsyncDialogs.ts", "utf8");
assert.doesNotMatch(treeItem, /defineAsyncComponent/);
assert.match(asyncDialogs, /loadingComponent: SidebarAsyncDialogLoading/);
assert.match(asyncDialogs, /errorComponent: SidebarAsyncDialogError/);
assert.match(asyncDialogs, /timeout: 15_000/);
});
test("tree host owns sidebar data-open generations", () => {
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
assert.doesNotMatch(treeItem, /runSidebarDataOpenImmediately/);
assert.match(treeItem, /emit\("open-data", node, true, openData\)/);
assert.match(connectionTree, /function openSidebarData/);
assert.match(connectionTree, /runSidebarDataOpenImmediately/);
assert.match(connectionTree, /createSidebarActionTarget\(node\)/);
});

View File

@ -41,3 +41,12 @@ test("non-SQL database nodes can be marked open without showing close database c
assert.equal(isSidebarDatabaseOpened(mongoDatabase, isLoaded), true);
assert.equal(canCloseSidebarDatabaseConnection(mongoDatabase, isLoaded), false);
});
test("database connections can be closed while an open tab still uses the database", () => {
const node = databaseNode("conn-1:app");
assert.equal(
canCloseSidebarDatabaseConnection(node, () => false, (connectionId, database) => connectionId === "conn-1" && database === "app"),
true,
);
});

View File

@ -0,0 +1,55 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
const dialogHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue", "utf8");
const dialogState = readFileSync("apps/desktop/src/components/sidebar/sidebarTreeDialogState.ts", "utf8");
function occurrences(source: string, value: string): number {
return source.split(value).length - 1;
}
test("sidebar routes destructive confirmations through one tree-level host", () => {
assert.match(treeItem, /emit\("open-danger-dialog", route\.createRequest\(\)\)/);
assert.match(connectionTree, /function openSidebarDangerDialog\(request: SidebarDangerDialogRequest\)/);
assert.equal(occurrences(connectionTree, "<SidebarDangerConfirmDialog"), 1);
assert.doesNotMatch(treeItem, /<DangerConfirmDialog/);
});
test("tree-level danger routing preserves cancel and close-on-confirm behavior", () => {
assert.match(connectionTree, /v-model:open="sidebarDangerDialogOpen"/);
assert.match(connectionTree, /if \(request\.closeOnConfirm !== false\) sidebarDangerDialogOpen\.value = false/);
assert.match(connectionTree, /await request\.confirm\(\)/);
assert.match(connectionTree, /sidebarDangerDialogOpen\.value = false/);
assert.match(connectionTree, /sidebarDangerDialogConfirming\.value = false/);
});
test("remaining form dialogs render once at tree level and keep confirm/cancel bindings", () => {
assert.equal(occurrences(connectionTree, "<SidebarTreeItemDialogs"), 1);
assert.doesNotMatch(treeItem, /<Dialog(?:\s|>)/);
assert.doesNotMatch(treeItem, /<InstallExtensionDialog/);
assert.match(dialogHost, /v-model:open="showCreateDatabaseDialog"/);
assert.match(dialogHost, /@click="showCreateDatabaseDialog = false"/);
assert.match(dialogHost, /@click="confirmCreateDatabase"/);
assert.match(dialogHost, /v-model:open="showPasteDialog"/);
assert.match(dialogHost, /@click="confirmPasteTable"/);
});
test("saved object dialogs refresh the immutable active target", () => {
assert.match(connectionTree, /<SidebarObjectSourceDialog[\s\S]*?@saved="refreshSidebarActionTarget"/);
assert.match(connectionTree, /<InstallExtensionDialog[\s\S]*?@close="refreshSidebarActionTarget"/);
assert.match(connectionTree, /findSidebarActionTarget\(store\.treeNodes, target\)/);
});
test("shared dialog state is owner-gated and confirm handlers use snapshots", () => {
assert.match(dialogState, /export const sidebarTreeDialogOwner = shallowRef<symbol \| null>\(null\)/);
assert.match(dialogState, /export const sidebarDangerTarget = shallowRef<TreeNode \| null>\(null\)/);
assert.match(dialogState, /export const sidebarFormTarget = shallowRef<TreeNode \| null>\(null\)/);
assert.match(treeItem, /sidebarTreeDialogOwner\.value !== treeItemDialogOwner/);
assert.match(treeItem, /const node = sidebarDangerTarget\.value \?\? props\.node/);
assert.match(treeItem, /const node = sidebarFormTarget\.value \?\? props\.node/);
assert.match(treeItem, /batchDropTargets\.value = targets\.slice\(\)/);
assert.match(treeItem, /batchTruncateTargets\.value = targets\.slice\(\)/);
});

View File

@ -0,0 +1,54 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createFlatTreeIndex, flattenTree } from "../../apps/desktop/src/composables/useFlatTree.ts";
import { filterSidebarTree } from "../../apps/desktop/src/lib/sidebar/sidebarSearchTree.ts";
import { scrollTopForSidebarNode } from "../../apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts";
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
function largeTree(): TreeNode[] {
return Array.from({ length: 5 }, (_, connectionIndex) => ({
id: `connection-${connectionIndex}`,
label: `connection-${connectionIndex}`,
type: "connection" as const,
isExpanded: true,
children: Array.from({ length: 5 }, (_, databaseIndex) => ({
id: `connection-${connectionIndex}:database-${databaseIndex}`,
label: `database-${databaseIndex}`,
type: "database" as const,
connectionId: `connection-${connectionIndex}`,
database: `database-${databaseIndex}`,
isExpanded: true,
children: Array.from({ length: 500 }, (_, tableIndex) => ({
id: `connection-${connectionIndex}:database-${databaseIndex}:table-${tableIndex}`,
label: `table_${connectionIndex}_${databaseIndex}_${tableIndex}`,
type: "table" as const,
connectionId: `connection-${connectionIndex}`,
database: `database-${databaseIndex}`,
})),
})),
}));
}
test("large sidebar tree keeps expansion, filtering and scroll indexes consistent", () => {
const tree = largeTree();
const flat = flattenTree(tree);
const index = createFlatTreeIndex(flat, {
isSelectable: () => true,
isBoundary: (type) => type === "connection" || type === "connection-group",
isDatabaseContainer: (type) => type === "database",
isSchemaContainer: (type) => type === "schema",
});
assert.equal(flat.length, 12_530);
const targetId = "connection-4:database-4:table-499";
const targetIndex = index.flatNodeIndexById.get(targetId);
assert.equal(typeof targetIndex, "number");
assert.equal(index.nodeById.get(targetId)?.label, "table_4_4_499");
assert.ok(scrollTopForSidebarNode({ index: targetIndex!, currentScrollTop: 0, viewportHeight: 560 }) > 0);
const filtered = flattenTree(filterSidebarTree(tree, "table_4_4_499", new Set()));
assert.deepEqual(
filtered.map((item) => item.id),
["connection-4", "connection-4:database-4", targetId],
);
});

View File

@ -0,0 +1,24 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
test("sidebar rows retain database-specific node affordances", () => {
for (const nodeType of ["connection", "database", "schema", "table", "column", "mongo-db", "mongo-collection", "redis-db", "nacos-namespace", "mq-tenant"]) {
assert.ok(treeItem.includes(`node.type === "${nodeType}"`) || treeItem.includes(`node.type === '${nodeType}'`), nodeType);
}
assert.match(treeItem, /@dblclick="onDoubleClick"/);
assert.match(treeItem, /@keydown="onKeydown"/);
assert.match(treeItem, /@mousedown="onRowMouseDown"/);
assert.match(treeItem, /@contextmenu="onTreeItemContextMenu"/);
});
test("complex tree changes retain the full rebuild fallback", () => {
assert.match(connectionTree, /const filteredNodes = computed/);
assert.match(connectionTree, /filterSidebarTree\(/);
assert.match(connectionTree, /const flatNodes = computed<FlatTreeNode\[]>/);
assert.match(connectionTree, /flattenTree\(filteredNodes\.value\)/);
assert.match(connectionTree, /watch\(flatNodes,/);
});

View File

@ -0,0 +1,26 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
test("recycled sidebar rows re-register paste handlers by node id", () => {
assert.match(treeItem, /watch\(\s*\(\) => props\.node\.id,/);
assert.match(treeItem, /registerPasteHandler\?\.\(nodeId, requestPasteTreeClipboard\)/);
assert.match(treeItem, /if \(unregister\) onCleanup\(unregister\)/);
});
test("sidebar row unmount clears observers, handlers and drag state", () => {
assert.match(treeItem, /function handleMouseLeave\(\)[\s\S]*?labelResizeObserver\?\.disconnect\(\)/);
assert.match(treeItem, /function finishTableReferenceDrag\(\)[\s\S]*?document\.removeEventListener\("mousemove"/);
assert.match(treeItem, /onBeforeUnmount\(\(\) => \{[\s\S]*?handleMouseLeave\(\)[\s\S]*?stopPasteHandlerRegistration\(\)[\s\S]*?finishTableReferenceDrag\(\)/);
assert.match(treeItem, /stopDangerDialogRouting\?\.\(\)/);
});
test("sidebar rows do not own dialog templates or eager dialog state", () => {
assert.doesNotMatch(treeItem, /<Dialog(?:\s|>)/);
assert.doesNotMatch(treeItem, /<DangerConfirmDialog/);
assert.doesNotMatch(treeItem, /const show[A-Z][A-Za-z]+(?:Dialog|Confirm) = ref\(/);
assert.match(treeItem, /function getTreeItemDialogController\(\)/);
assert.match(treeItem, /if \(treeItemDialogController\) return treeItemDialogController/);
});