2208 lines
96 KiB
Vue
2208 lines
96 KiB
Vue
<script setup lang="ts">
|
||
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
|
||
import { useConnectionStore } from "@/stores/connectionStore";
|
||
import { useQueryStore } from "@/stores/queryStore";
|
||
import { useSettingsStore } from "@/stores/settingsStore";
|
||
import { useToast } from "@/composables/useToast";
|
||
import type { ObjectSourceKind, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
|
||
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree";
|
||
import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
|
||
import { buildTableTreeNodes } from "@/lib/table/tableTree";
|
||
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
|
||
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
|
||
import { copyNameForTreeNode, objectSourceKindForTreeNode } from "@/lib/sidebar/treeNodeClick";
|
||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||
import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
|
||
import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch";
|
||
import { usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport";
|
||
import { connectionObjectTreeNodeSchema, 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 { 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 { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime";
|
||
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
|
||
import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl";
|
||
import TreeItem from "./TreeItem.vue";
|
||
import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue";
|
||
import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue";
|
||
import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue";
|
||
import ExtensionDetailsDialog from "@/components/objects/ExtensionDetailsDialog.vue";
|
||
import { RecycleScroller } from "vue-virtual-scroller";
|
||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { cancelPendingSidebarDataOpen, runSidebarDataOpenImmediately, type SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
|
||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { codeMirrorSqlDialect } from "@/lib/database/jdbcDialect";
|
||
import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
|
||
import { createSidebarActionTarget, findSidebarActionTarget, matchesSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
|
||
import { syncSidebarTreeNodeExpansion } from "@/lib/sidebar/sidebarTreeExpansion";
|
||
import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDialog";
|
||
import { resetSidebarTreeDialogState } from "./sidebarTreeDialogState";
|
||
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleNacosNamespacesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
|
||
import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
|
||
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
|
||
import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
|
||
import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
|
||
|
||
const { t } = useI18n();
|
||
const store = useConnectionStore();
|
||
const queryStore = useQueryStore();
|
||
const settingsStore = useSettingsStore();
|
||
const { toast } = useToast();
|
||
const searchQuery = ref("");
|
||
const deferredSearchQuery = ref("");
|
||
const showConnectedConnectionsOnly = ref(false);
|
||
const searchInputRef = ref<HTMLInputElement>();
|
||
const rootRef = ref<HTMLElement>();
|
||
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 sidebarHorizontalScrollbarTrackRef = ref<HTMLElement | null>(null);
|
||
const sidebarContextMenuRef = ref<{ close: () => void } | null>(null);
|
||
const sidebarContextMenuItems = ref<ContextMenuItem[]>([]);
|
||
const emit = defineEmits<{
|
||
"open-settings": [initialTab: string];
|
||
}>();
|
||
|
||
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 sidebarExtensionDetailsTarget = ref<TreeNode | null>(null);
|
||
const sidebarExtensionDetailsDialogRef = ref<InstanceType<typeof ExtensionDetailsDialog> | null>(null);
|
||
const sidebarTreeRuntimeHostRef = ref<SidebarTreeRuntimeHostInstance | null>(null);
|
||
const sidebarTreeRuntime = createSidebarTreeRuntime();
|
||
const sidebarTreeRuntimeInitialNode: TreeNode = { id: "__sidebar-runtime__", label: "", type: "connection-group" };
|
||
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);
|
||
const sidebarVisibleNacosNamespacesTarget = ref<TreeNode | null>(null);
|
||
const sidebarVisibleNacosNamespacesOpen = ref(false);
|
||
const sidebarTableNameFilterTarget = ref<TreeNode | null>(null);
|
||
const sidebarTableNameFilterOpen = ref(false);
|
||
const tableNameFilterIncludeDraft = ref("");
|
||
const tableNameFilterExcludeDraft = ref("");
|
||
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());
|
||
const searchRefreshedNodeIds = new Set<string>();
|
||
let searchTimer: number | undefined;
|
||
const tableSearchTimers = new Map<string, number>();
|
||
const tableSearchFocusRestoreTokens = new Map<string, number>();
|
||
let tableSearchFocusRestoreTokenSeq = 0;
|
||
let latestTableSearchInteractionParentId: string | null = null;
|
||
let latestTableSearchInteractionId = 0;
|
||
let tableSearchInteractionIdSeq = 0;
|
||
let localTableSearchFocusPending = false;
|
||
|
||
type TableSearchSelection = {
|
||
start: number;
|
||
end: number;
|
||
direction: "forward" | "backward" | "none";
|
||
};
|
||
|
||
type TableSearchFocusRestore = {
|
||
interactionId: number;
|
||
parentNodeId: string;
|
||
shouldRestoreFocus: boolean;
|
||
selection: TableSearchSelection | null;
|
||
};
|
||
|
||
watch(
|
||
searchQuery,
|
||
(value) => {
|
||
const normalized = value.trim().toLowerCase();
|
||
window.clearTimeout(searchTimer);
|
||
|
||
if (!normalized) {
|
||
deferredSearchQuery.value = "";
|
||
return;
|
||
}
|
||
|
||
searchTimer = window.setTimeout(() => {
|
||
deferredSearchQuery.value = normalized;
|
||
}, 300);
|
||
},
|
||
{ flush: "sync" },
|
||
);
|
||
|
||
function refreshActiveSidebarTableSearches() {
|
||
if (isTreeSearchFiltering.value) return;
|
||
for (const parentNodeId of Object.keys(store.sidebarTableSearchQueries)) {
|
||
scheduleSidebarTableSearchRefresh(parentNodeId);
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => settingsStore.editorSettings.sidebarTableSearchEnabled,
|
||
(enabled) => {
|
||
if (enabled) return;
|
||
const parentNodeIds = Object.keys(store.sidebarTableSearchQueries);
|
||
if (parentNodeIds.length === 0) return;
|
||
|
||
for (const parentNodeId of parentNodeIds) {
|
||
window.clearTimeout(tableSearchTimers.get(parentNodeId));
|
||
tableSearchTimers.delete(parentNodeId);
|
||
tableSearchFocusRestoreTokens.delete(parentNodeId);
|
||
store.setSidebarTableSearchQuery(parentNodeId, "");
|
||
}
|
||
latestTableSearchInteractionParentId = null;
|
||
latestTableSearchInteractionId = 0;
|
||
void Promise.all(parentNodeIds.map((parentNodeId) => store.refreshSidebarTableSearch(parentNodeId))).catch(() => {});
|
||
},
|
||
);
|
||
|
||
watch(deferredSearchQuery, (newQuery, oldQuery) => {
|
||
store.sidebarSearchQuery = newQuery;
|
||
if (settingsStore.editorSettings.sidebarGlobalSearchLocal) {
|
||
if (!newQuery && oldQuery) searchRefreshedNodeIds.clear();
|
||
return;
|
||
}
|
||
const tasks: Promise<void>[] = [];
|
||
for (const root of store.treeNodes) {
|
||
collectExpandedObjectSearchTargets(root, tasks, newQuery ? searchRefreshedNodeIds : undefined);
|
||
}
|
||
if (!newQuery && oldQuery) {
|
||
searchRefreshedNodeIds.clear();
|
||
}
|
||
Promise.all(tasks)
|
||
.then(() => {
|
||
if (!newQuery && oldQuery) refreshActiveSidebarTableSearches();
|
||
})
|
||
.catch(() => {});
|
||
});
|
||
|
||
const searchableObjectGroupTypes = new Set<TreeNodeType>(["group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-synonyms", "group-packages", "group-types"]);
|
||
const simpleObjectParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema"]);
|
||
const simpleObjectChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "procedure", "function", "trigger", "sequence", "synonym", "package", "package-body", "type", "type-body", "load-more"]);
|
||
|
||
function isSimpleObjectSearchParent(node: TreeNode): boolean {
|
||
return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && (!!node.children?.some((child) => simpleObjectChildTypes.has(child.type)) || !!store.sidebarTableSearchQueries[node.id]?.trim());
|
||
}
|
||
|
||
function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>[], refreshedNodeIds?: Set<string>) {
|
||
if (refreshedNodeIds && node.type === "connection" && node.connectionId) {
|
||
if (store.connectedIds.has(node.connectionId)) {
|
||
tasks.push(store.loadConnectedConnectionRootForSidebarSearch(node.connectionId));
|
||
}
|
||
if (node.connectionId !== store.activeConnectionId) return;
|
||
}
|
||
if (refreshedNodeIds && isSimpleObjectSearchParent(node)) {
|
||
refreshedNodeIds.add(node.id);
|
||
tasks.push(store.refreshTreeNode(node));
|
||
return;
|
||
}
|
||
if (refreshedNodeIds && node.isExpanded && node.children) {
|
||
for (const child of node.children) {
|
||
if (child.connectionId && searchableObjectGroupTypes.has(child.type)) {
|
||
refreshedNodeIds.add(child.id);
|
||
tasks.push(store.loadObjectGroupChildren(child, { force: true }));
|
||
}
|
||
}
|
||
} else if (!refreshedNodeIds && searchRefreshedNodeIds.has(node.id)) {
|
||
if (searchableObjectGroupTypes.has(node.type)) {
|
||
tasks.push(store.loadObjectGroupChildren(node, { force: true }));
|
||
} else if (simpleObjectParentTypes.has(node.type)) {
|
||
tasks.push(store.refreshTreeNode(node));
|
||
}
|
||
}
|
||
if (node.children) {
|
||
for (const child of node.children) {
|
||
collectExpandedObjectSearchTargets(child, tasks, refreshedNodeIds);
|
||
}
|
||
}
|
||
}
|
||
|
||
const sidebarFilterGuards = computed(() => resolveSidebarFilterGuards(showConnectedConnectionsOnly.value, searchQuery.value, hasSearchScopeFilter.value));
|
||
// Connected-only filtering changes only root visibility, so descendant-local
|
||
// features stay available while operations requiring the full root list pause.
|
||
const isTreeSearchFiltering = computed(() => sidebarFilterGuards.value.isTreeSearchFiltering);
|
||
const isRootListPartial = computed(() => sidebarFilterGuards.value.isRootListPartial);
|
||
|
||
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||
connection: ["connection"],
|
||
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "mongo-db"],
|
||
schema: ["schema"],
|
||
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
|
||
view: ["view"],
|
||
};
|
||
|
||
// Sticky-row container types. When browsing a large number of children (e.g.
|
||
// hundreds of tables) under one of these and scrolling down, the row is kept
|
||
// pinned at the top so the active container stays identifiable and can be
|
||
// collapsed with one click.
|
||
//
|
||
// Database-level containers are always preferred. Schema is only a fallback,
|
||
// used when the upward path has NO database-level ancestor at all: Dameng /
|
||
// Oracle / oceanbase-oracle expose `connection -> schema -> tables` (no database
|
||
// node, via connectionUsesVisibleSchemaFilter). For Postgres/SQLServer, whose
|
||
// tree is `connection -> database -> schema -> tables`, the sticky walk prefers
|
||
// the database node, so schema never shadows it.
|
||
const DATABASE_LEVEL_TYPES = new Set<TreeNodeType>(SEARCH_SCOPE_TO_NODE_TYPES.database);
|
||
const SCHEMA_LEVEL_TYPES = new Set<TreeNodeType>(["schema"]);
|
||
|
||
const searchScopeOptions = computed(() => {
|
||
return [
|
||
{ scope: "connection", label: t("sidebar.searchScopeConnection"), icon: Server },
|
||
{ scope: "database", label: t("sidebar.searchScopeDatabase"), icon: Database },
|
||
{ scope: "schema", label: t("sidebar.searchScopeSchema"), icon: FolderTree },
|
||
{ scope: "table", label: t("sidebar.searchScopeTable"), icon: Table2 },
|
||
{ scope: "view", label: t("sidebar.searchScopeView"), icon: Eye },
|
||
] as const satisfies ReadonlyArray<{ scope: SearchScope; label: string; icon: Component }>;
|
||
});
|
||
const searchScopeMenuItems = computed(() => [
|
||
...searchScopeOptions.value.map((item) => ({
|
||
value: item.scope,
|
||
label: item.label,
|
||
icon: item.icon,
|
||
})),
|
||
...(hasSearchScopeFilter.value
|
||
? [
|
||
{
|
||
value: "__clear",
|
||
label: t("sidebar.clearFilter"),
|
||
icon: RotateCcw,
|
||
separatorBefore: true,
|
||
},
|
||
]
|
||
: []),
|
||
]);
|
||
|
||
const connectionListSortMenuItems = computed(() => [
|
||
{ value: "manual", label: t("sidebar.sortConnectionsManual"), icon: ListOrdered },
|
||
{ value: "asc", label: t("sidebar.sortConnectionsAscending"), icon: ArrowDownAZ },
|
||
{ value: "desc", label: t("sidebar.sortConnectionsDescending"), icon: ArrowUpZA },
|
||
]);
|
||
|
||
const isConnectionListAlphabeticallySorted = computed(() => settingsStore.editorSettings.sidebarConnectionSortMode !== "manual");
|
||
|
||
function updateConnectionListSortMode(mode: string) {
|
||
if (mode === "manual" || mode === "asc" || mode === "desc") {
|
||
settingsStore.updateEditorSettings({ sidebarConnectionSortMode: mode });
|
||
}
|
||
}
|
||
|
||
const hasSearchScopeFilter = computed(() => selectedSearchScopes.value.length > 0);
|
||
const searchableNodeTypes = computed<Set<TreeNodeType> | undefined>(() => {
|
||
if (!hasSearchScopeFilter.value) return undefined;
|
||
const types = new Set<TreeNodeType>();
|
||
for (const scope of selectedSearchScopes.value) {
|
||
for (const nodeType of SEARCH_SCOPE_TO_NODE_TYPES[scope]) {
|
||
types.add(nodeType);
|
||
}
|
||
}
|
||
return types;
|
||
});
|
||
|
||
function toggleSearchScope(scope: SearchScope) {
|
||
const idx = selectedSearchScopes.value.indexOf(scope);
|
||
if (idx >= 0) {
|
||
selectedSearchScopes.value.splice(idx, 1);
|
||
} else {
|
||
selectedSearchScopes.value.push(scope);
|
||
}
|
||
}
|
||
|
||
function selectSearchScopeMenuItem(value: string) {
|
||
if (value === "__clear") {
|
||
clearSearchScopeFilter();
|
||
return;
|
||
}
|
||
toggleSearchScope(value as SearchScope);
|
||
}
|
||
|
||
function clearSearchScopeFilter() {
|
||
selectedSearchScopes.value = [];
|
||
}
|
||
|
||
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) {
|
||
window.clearTimeout(tableSearchTimers.get(parentNodeId));
|
||
if (isTreeSearchFiltering.value) return;
|
||
const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
|
||
if (restoreToken) {
|
||
tableSearchFocusRestoreTokens.clear();
|
||
tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken);
|
||
}
|
||
const timer = window.setTimeout(() => {
|
||
tableSearchTimers.delete(parentNodeId);
|
||
void store.refreshSidebarTableSearch(parentNodeId).then(() => {
|
||
if (!restoreToken) return;
|
||
if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return;
|
||
tableSearchFocusRestoreTokens.delete(parentNodeId);
|
||
const focusRestore = options?.focusRestore;
|
||
if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return;
|
||
restoreTableSearchInput(focusRestore);
|
||
});
|
||
}, 250);
|
||
tableSearchTimers.set(parentNodeId, timer);
|
||
}
|
||
|
||
function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore {
|
||
const interactionId = ++tableSearchInteractionIdSeq;
|
||
const active = document.activeElement;
|
||
const isActiveSearchInput = active instanceof HTMLInputElement && active.dataset.sidebarTableSearchParentId === parentNodeId;
|
||
|
||
return {
|
||
interactionId,
|
||
parentNodeId,
|
||
shouldRestoreFocus: isActiveSearchInput,
|
||
selection: isActiveSearchInput
|
||
? {
|
||
start: active.selectionStart ?? active.value.length,
|
||
end: active.selectionEnd ?? active.value.length,
|
||
direction: active.selectionDirection ?? "none",
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function isCurrentTableSearchInteraction(focusRestore: TableSearchFocusRestore): boolean {
|
||
return latestTableSearchInteractionParentId === focusRestore.parentNodeId && latestTableSearchInteractionId === focusRestore.interactionId;
|
||
}
|
||
|
||
function restoreTableSearchInput(focusRestore: TableSearchFocusRestore) {
|
||
void nextTick(() => {
|
||
if (!isCurrentTableSearchInteraction(focusRestore) || !focusRestore.shouldRestoreFocus) return;
|
||
const root = rootRef.value;
|
||
if (!root) return;
|
||
const input = Array.from(root.querySelectorAll<HTMLInputElement>("[data-sidebar-table-search-parent-id]")).find((item) => item.dataset.sidebarTableSearchParentId === focusRestore.parentNodeId);
|
||
if (!input) return;
|
||
|
||
// Keep the browser's current selection when the tree update preserved the
|
||
// input element. Only restore focus and selection if the async update
|
||
// actually displaced focus or recreated the input.
|
||
if (document.activeElement === input) return;
|
||
if (document.activeElement !== document.body) return;
|
||
|
||
input.focus({ preventScroll: true });
|
||
const selection = focusRestore.selection;
|
||
if (!selection) {
|
||
const end = input.value.length;
|
||
input.setSelectionRange(end, end);
|
||
return;
|
||
}
|
||
|
||
const valueLength = input.value.length;
|
||
const start = Math.min(selection.start, valueLength);
|
||
const end = Math.min(selection.end, valueLength);
|
||
input.setSelectionRange(start, end, selection.direction);
|
||
});
|
||
}
|
||
|
||
const displayedTreeNodes = computed(() => sortConnectionListForDisplay(store.treeNodes, settingsStore.editorSettings.sidebarConnectionSortMode));
|
||
const localTableSearchResults = ref<Record<string, TableInfo[] | null>>({});
|
||
|
||
const localTableSearchParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
|
||
const localTableSearchChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
|
||
|
||
function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] {
|
||
return nodes.map((node) => {
|
||
const children = node.children ? filterLocallySearchedTables(node.children) : undefined;
|
||
const query = settingsStore.editorSettings.sidebarTableSearchLocal && localTableSearchParentTypes.has(node.type) ? store.sidebarTableSearchQueries[node.id]?.trim() : "";
|
||
if (!query || !children) return children === node.children ? node : { ...node, children };
|
||
|
||
const indexed = localTableSearchResults.value[node.id];
|
||
// matchSidebarLabel compares case-insensitively internally and needs the
|
||
// ORIGINAL label (and entry name) so camelCase boundaries stay detectable.
|
||
const matchingChildren =
|
||
indexed === null
|
||
? children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query))
|
||
: indexed
|
||
? reuseLiveSidebarTreeNodes(buildTableTreeNodes({ nodeId: node.id, connectionId: node.connectionId || "", database: node.database || "", schema: node.schema, catalog: node.catalog, tables: indexed.filter((entry) => !!matchSidebarLabel(entry.name, query)) }), children)
|
||
: children.filter((child) => localTableSearchChildTypes.has(child.type) && !!matchSidebarLabel(child.label, query));
|
||
return { ...node, children: matchingChildren };
|
||
});
|
||
}
|
||
|
||
async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) {
|
||
try {
|
||
const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId);
|
||
localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries };
|
||
} finally {
|
||
if (focusRestore) restoreTableSearchInput(focusRestore);
|
||
}
|
||
}
|
||
|
||
const filteredNodes = computed(() => {
|
||
let nodes = displayedTreeNodes.value;
|
||
if (showConnectedConnectionsOnly.value) {
|
||
nodes = filterSidebarTreeToConnectedConnections(nodes, store.connectedIds);
|
||
}
|
||
|
||
nodes = filterLocallySearchedTables(nodes);
|
||
|
||
const q = deferredSearchQuery.value;
|
||
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value);
|
||
if (q) {
|
||
nodes = filterSidebarSearchRootsByConnectionState(nodes, store.connectedIds);
|
||
}
|
||
|
||
return nodes;
|
||
});
|
||
|
||
const flatNodes = computed<FlatTreeNode[]>(() =>
|
||
insertSidebarTableSearchControls(flattenTree(filteredNodes.value), {
|
||
enabled: settingsStore.editorSettings.sidebarTableSearchEnabled && !isTreeSearchFiltering.value,
|
||
sidebarObjectDisplay: settingsStore.editorSettings.sidebarObjectDisplay,
|
||
activeQueries: store.sidebarTableSearchQueries,
|
||
}),
|
||
);
|
||
|
||
const sidebarCommentLabelWidths = shallowRef(new Map<string, number>());
|
||
let sidebarCommentMeasureFrame = 0;
|
||
const sidebarTreeContentWidth = ref(0);
|
||
let sidebarTreeContentMeasureFrame = 0;
|
||
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]);
|
||
const sidebarStorageDisplayTypes = new Set<TreeNodeType>(["database", "table", "materialized_view"]);
|
||
|
||
function sidebarCommentLabel(node: TreeNode): string {
|
||
const label = sidebarTableNameDisplayTypes.has(node.type) ? sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes) : node.label;
|
||
return node.valid === false ? `${label} · INVALID` : label;
|
||
}
|
||
|
||
function measureSidebarCommentLabelWidths() {
|
||
sidebarCommentMeasureFrame = 0;
|
||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "comment-aligned" || typeof document === "undefined" || !rootRef.value) {
|
||
sidebarCommentLabelWidths.value = new Map();
|
||
return;
|
||
}
|
||
|
||
const context = document.createElement("canvas").getContext("2d");
|
||
if (!context) return;
|
||
const style = window.getComputedStyle(rootRef.value);
|
||
context.font = style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||
sidebarCommentLabelWidths.value = alignedSidebarCommentLabelWidths(
|
||
flatNodes.value.map(({ id, depth, node }) => ({
|
||
id,
|
||
depth,
|
||
alignable: isSidebarCommentAlignableNode(node),
|
||
hasComment: !!sidebarTreeNodeComment(node),
|
||
labelWidth: context.measureText(sidebarCommentLabel(node)).width,
|
||
})),
|
||
);
|
||
}
|
||
|
||
function scheduleSidebarCommentLabelMeasure() {
|
||
if (typeof window === "undefined") {
|
||
measureSidebarCommentLabelWidths();
|
||
return;
|
||
}
|
||
if (sidebarCommentMeasureFrame) window.cancelAnimationFrame(sidebarCommentMeasureFrame);
|
||
sidebarCommentMeasureFrame = window.requestAnimationFrame(measureSidebarCommentLabelWidths);
|
||
}
|
||
|
||
function sidebarNodeHasTrailingMetadata(node: TreeNode): boolean {
|
||
const mode = settingsStore.editorSettings.sidebarObjectInfoMode;
|
||
if (mode.startsWith("comment-") && sidebarTreeNodeComment(node)) return true;
|
||
return mode === "size" && sidebarStorageDisplayTypes.has(node.type) && !!formatSidebarObjectStorage(node.sizeBytes);
|
||
}
|
||
|
||
const sidebarTreeNaturalWidthItems = computed(() =>
|
||
flatNodes.value.map(({ depth, node }) => ({
|
||
depth,
|
||
label: sidebarCommentLabel(node),
|
||
usesNaturalWidth: usesFullWidthTreeLabel(node.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll, sidebarNodeHasTrailingMetadata(node)),
|
||
trailingWidth: node.pinned || store.isTreeNodePinned(node) ? 20 : 0,
|
||
})),
|
||
);
|
||
|
||
function measureSidebarTreeContentWidth() {
|
||
sidebarTreeContentMeasureFrame = 0;
|
||
if (!settingsStore.editorSettings.sidebarAllowHorizontalScroll || typeof document === "undefined" || !rootRef.value) {
|
||
sidebarTreeContentWidth.value = 0;
|
||
return;
|
||
}
|
||
|
||
const context = document.createElement("canvas").getContext("2d");
|
||
if (!context) return;
|
||
const style = window.getComputedStyle(rootRef.value);
|
||
context.font = style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||
sidebarTreeContentWidth.value = sidebarTreeNaturalContentWidth(sidebarTreeNaturalWidthItems.value, (text) => context.measureText(text).width);
|
||
void nextTick(scheduleSidebarScrollMetricsUpdate);
|
||
}
|
||
|
||
function scheduleSidebarTreeContentWidthMeasure() {
|
||
if (typeof window === "undefined") {
|
||
measureSidebarTreeContentWidth();
|
||
return;
|
||
}
|
||
if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame);
|
||
sidebarTreeContentMeasureFrame = window.requestAnimationFrame(measureSidebarTreeContentWidth);
|
||
}
|
||
|
||
watch([flatNodes, () => settingsStore.editorSettings.sidebarObjectInfoMode, () => settingsStore.editorSettings.sidebarHiddenTablePrefixes, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale], scheduleSidebarCommentLabelMeasure, {
|
||
flush: "post",
|
||
immediate: true,
|
||
});
|
||
|
||
watch([sidebarTreeNaturalWidthItems, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale], scheduleSidebarTreeContentWidthMeasure, {
|
||
flush: "post",
|
||
immediate: true,
|
||
});
|
||
|
||
const visibleSidebarTableStorageScopes = computed(() => {
|
||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return [];
|
||
return sidebarTableStorageScopes(flatNodes.value.map(({ node }) => node)).filter((scope) => supportsSidebarTableStorage(store.getConfig(scope.connectionId)));
|
||
});
|
||
|
||
watch(
|
||
visibleSidebarTableStorageScopes,
|
||
(scopes) => {
|
||
for (const scope of scopes) void store.loadSidebarTableStorage(scope);
|
||
},
|
||
{ flush: "post", immediate: true },
|
||
);
|
||
// 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));
|
||
|
||
// --- Sticky database header ---
|
||
// RecycleScroller positions each row absolutely, so CSS `position: sticky` on
|
||
// a database row can't work. Instead we overlay a pinned row from this parent
|
||
// component, tracking scroll offset to find the topmost visible database-level
|
||
// ancestor. The overlay reuses <TreeItem>, so collapse/expand comes for free.
|
||
const stickyScrollTop = ref(0);
|
||
const sidebarScrollMetrics = ref({ scrollTop: 0, scrollLeft: 0, clientHeight: 0, clientWidth: 0, scrollHeight: 0, scrollWidth: 0 });
|
||
const isScrollingSidebar = ref(false);
|
||
const isDraggingSidebarScrollbar = ref(false);
|
||
const isDraggingSidebarHorizontalScrollbar = ref(false);
|
||
let sidebarScrollbarResizeObserver: ResizeObserver | null = null;
|
||
let sidebarScrollbarAnimationFrame = 0;
|
||
let sidebarScrollbarDragOffset = 0;
|
||
let sidebarHorizontalScrollbarDragOffset = 0;
|
||
let sidebarScrollingTimer = 0;
|
||
|
||
function updateSidebarScrollMetrics() {
|
||
const scroller = currentTreeScroller();
|
||
if (!scroller) {
|
||
sidebarScrollMetrics.value = { scrollTop: 0, scrollLeft: 0, clientHeight: 0, clientWidth: 0, scrollHeight: 0, scrollWidth: 0 };
|
||
return;
|
||
}
|
||
|
||
if (useVirtualTree.value) stickyScrollTop.value = scroller.scrollTop;
|
||
sidebarScrollMetrics.value = {
|
||
scrollTop: scroller.scrollTop,
|
||
scrollLeft: scroller.scrollLeft,
|
||
clientHeight: scroller.clientHeight,
|
||
clientWidth: scroller.clientWidth,
|
||
scrollHeight: scroller.scrollHeight,
|
||
scrollWidth: Math.max(scroller.scrollWidth, sidebarTreeContentWidth.value),
|
||
};
|
||
}
|
||
|
||
function scheduleSidebarScrollMetricsUpdate() {
|
||
window.cancelAnimationFrame(sidebarScrollbarAnimationFrame);
|
||
sidebarScrollbarAnimationFrame = window.requestAnimationFrame(updateSidebarScrollMetrics);
|
||
}
|
||
|
||
function onTreeScroll() {
|
||
isScrollingSidebar.value = true;
|
||
window.clearTimeout(sidebarScrollingTimer);
|
||
sidebarScrollingTimer = window.setTimeout(() => {
|
||
isScrollingSidebar.value = false;
|
||
}, 700);
|
||
scheduleSidebarScrollMetricsUpdate();
|
||
}
|
||
|
||
// RecycleScroller only emits scrollStart/scrollEnd, not continuous scroll, so
|
||
// attach a native passive listener on its root element once it mounts.
|
||
watch(
|
||
treeScrollerRef,
|
||
(scroller, _old, onCleanup) => {
|
||
const el = (scroller?.$el as HTMLElement | undefined) ?? null;
|
||
if (!el) return;
|
||
el.addEventListener("scroll", onTreeScroll, { passive: true });
|
||
onCleanup(() => el.removeEventListener("scroll", onTreeScroll));
|
||
},
|
||
{ flush: "post" },
|
||
);
|
||
|
||
watch(
|
||
[treeScrollerRef, plainTreeScrollerRef, useVirtualTree],
|
||
(_value, _oldValue, onCleanup) => {
|
||
sidebarScrollbarResizeObserver?.disconnect();
|
||
sidebarScrollbarResizeObserver = null;
|
||
|
||
const scroller = currentTreeScroller();
|
||
if (!scroller) return;
|
||
|
||
sidebarScrollbarResizeObserver = new ResizeObserver(scheduleSidebarScrollMetricsUpdate);
|
||
sidebarScrollbarResizeObserver.observe(scroller);
|
||
const content = scroller.querySelector<HTMLElement>(".connection-tree-content");
|
||
if (content) sidebarScrollbarResizeObserver.observe(content);
|
||
scheduleSidebarScrollMetricsUpdate();
|
||
|
||
onCleanup(() => {
|
||
sidebarScrollbarResizeObserver?.disconnect();
|
||
sidebarScrollbarResizeObserver = null;
|
||
});
|
||
},
|
||
{ flush: "post" },
|
||
);
|
||
|
||
const stickyNode = computed<FlatTreeNode | null>(() => {
|
||
if (!useVirtualTree.value || isTreeSearchFiltering.value) return null;
|
||
const nodes = flatNodes.value;
|
||
const len = nodes.length;
|
||
if (len === 0) return null;
|
||
|
||
const topIndex = Math.min(Math.floor(stickyScrollTop.value / SIDEBAR_TREE_ROW_HEIGHT), len - 1);
|
||
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 currentIndex = flatTreeIndex.value.flatNodeIndexById.get(node.id) ?? -1;
|
||
if (currentIndex < 0) return {};
|
||
// 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 {};
|
||
return {
|
||
transform: `translateY(${Math.min(0, distanceToNext - SIDEBAR_TREE_ROW_HEIGHT)}px)`,
|
||
};
|
||
});
|
||
|
||
// 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, (nodes) => {
|
||
const contextMenuTarget = sidebarContextMenuTarget.value;
|
||
if (contextMenuTarget) {
|
||
const visibleContextMenuTarget = nodes.find(({ node }) => matchesSidebarActionTarget(node, contextMenuTarget))?.node;
|
||
if (!visibleContextMenuTarget || visibleContextMenuTarget.valid === false) {
|
||
sidebarContextMenuRef.value?.close();
|
||
sidebarContextMenuItems.value = [];
|
||
sidebarContextMenuTarget.value = null;
|
||
}
|
||
}
|
||
stickyScrollTop.value = 0;
|
||
void nextTick(scheduleSidebarScrollMetricsUpdate);
|
||
});
|
||
|
||
const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden"));
|
||
const sidebarTreeScrollerStyle = computed<CSSProperties>(() => ({ "--sidebar-tree-content-width": `${sidebarTreeContentWidth.value}px` }) as CSSProperties);
|
||
|
||
const hasSidebarVerticalOverflow = computed(() => sidebarScrollMetrics.value.scrollHeight > sidebarScrollMetrics.value.clientHeight + 1);
|
||
const hasSidebarHorizontalOverflow = computed(() => settingsStore.editorSettings.sidebarAllowHorizontalScroll && sidebarScrollMetrics.value.scrollWidth > sidebarScrollMetrics.value.clientWidth + 1);
|
||
|
||
watch(
|
||
() => settingsStore.editorSettings.sidebarAllowHorizontalScroll,
|
||
(enabled) =>
|
||
void nextTick(() => {
|
||
const scroller = currentTreeScroller();
|
||
if (!enabled && scroller) scroller.scrollLeft = 0;
|
||
scheduleSidebarScrollMetricsUpdate();
|
||
}),
|
||
{ flush: "post" },
|
||
);
|
||
|
||
function sidebarScrollbarGeometry() {
|
||
const { scrollTop, clientHeight, scrollHeight } = sidebarScrollMetrics.value;
|
||
const trackHeight = sidebarScrollbarTrackRef.value?.clientHeight ?? Math.max(0, clientHeight - 8);
|
||
const { thumbOffset, thumbSize, maxThumbOffset, maxScrollOffset } = calculateSidebarScrollbarGeometry({
|
||
scrollOffset: scrollTop,
|
||
viewportSize: clientHeight,
|
||
contentSize: scrollHeight,
|
||
trackSize: trackHeight,
|
||
});
|
||
return { thumbTop: thumbOffset, thumbHeight: thumbSize, maxThumbTop: maxThumbOffset, maxScrollTop: maxScrollOffset };
|
||
}
|
||
|
||
const sidebarScrollbarThumbStyle = computed<CSSProperties>(() => {
|
||
const { thumbTop, thumbHeight } = sidebarScrollbarGeometry();
|
||
return {
|
||
height: `${thumbHeight}px`,
|
||
transform: `translateY(${thumbTop}px)`,
|
||
};
|
||
});
|
||
|
||
function setSidebarScrollFromPointer(clientY: number, offset: number) {
|
||
const scroller = currentTreeScroller();
|
||
const track = sidebarScrollbarTrackRef.value;
|
||
if (!scroller || !track) return;
|
||
|
||
const rect = track.getBoundingClientRect();
|
||
const { maxThumbTop, maxScrollTop } = sidebarScrollbarGeometry();
|
||
if (maxThumbTop <= 0) return;
|
||
|
||
const thumbTop = Math.min(maxThumbTop, Math.max(0, clientY - rect.top - offset));
|
||
scroller.scrollTop = (thumbTop / maxThumbTop) * maxScrollTop;
|
||
updateSidebarScrollMetrics();
|
||
}
|
||
|
||
function stopSidebarScrollbarDrag() {
|
||
isDraggingSidebarScrollbar.value = false;
|
||
window.removeEventListener("pointermove", onSidebarScrollbarPointerMove);
|
||
window.removeEventListener("pointerup", stopSidebarScrollbarDrag);
|
||
window.removeEventListener("pointercancel", stopSidebarScrollbarDrag);
|
||
}
|
||
|
||
function onSidebarScrollbarPointerMove(event: PointerEvent) {
|
||
event.preventDefault();
|
||
setSidebarScrollFromPointer(event.clientY, sidebarScrollbarDragOffset);
|
||
}
|
||
|
||
function onSidebarScrollbarTrackPointerDown(event: PointerEvent) {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault();
|
||
const { thumbHeight } = sidebarScrollbarGeometry();
|
||
sidebarScrollbarDragOffset = thumbHeight / 2;
|
||
setSidebarScrollFromPointer(event.clientY, sidebarScrollbarDragOffset);
|
||
isDraggingSidebarScrollbar.value = true;
|
||
window.addEventListener("pointermove", onSidebarScrollbarPointerMove);
|
||
window.addEventListener("pointerup", stopSidebarScrollbarDrag);
|
||
window.addEventListener("pointercancel", stopSidebarScrollbarDrag);
|
||
}
|
||
|
||
function onSidebarScrollbarThumbPointerDown(event: PointerEvent) {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault();
|
||
const track = sidebarScrollbarTrackRef.value;
|
||
if (!track) return;
|
||
|
||
const rect = track.getBoundingClientRect();
|
||
const { thumbTop } = sidebarScrollbarGeometry();
|
||
sidebarScrollbarDragOffset = event.clientY - rect.top - thumbTop;
|
||
isDraggingSidebarScrollbar.value = true;
|
||
window.addEventListener("pointermove", onSidebarScrollbarPointerMove);
|
||
window.addEventListener("pointerup", stopSidebarScrollbarDrag);
|
||
window.addEventListener("pointercancel", stopSidebarScrollbarDrag);
|
||
}
|
||
|
||
function sidebarHorizontalScrollbarGeometry() {
|
||
const { scrollLeft, clientWidth, scrollWidth } = sidebarScrollMetrics.value;
|
||
const trackWidth = sidebarHorizontalScrollbarTrackRef.value?.clientWidth ?? clientWidth;
|
||
const { thumbOffset, thumbSize, maxThumbOffset, maxScrollOffset } = calculateSidebarScrollbarGeometry({
|
||
scrollOffset: scrollLeft,
|
||
viewportSize: clientWidth,
|
||
contentSize: scrollWidth,
|
||
trackSize: trackWidth,
|
||
});
|
||
return { thumbLeft: thumbOffset, thumbWidth: thumbSize, maxThumbLeft: maxThumbOffset, maxScrollLeft: maxScrollOffset };
|
||
}
|
||
|
||
const sidebarHorizontalScrollbarThumbStyle = computed<CSSProperties>(() => {
|
||
const { thumbLeft, thumbWidth } = sidebarHorizontalScrollbarGeometry();
|
||
return {
|
||
width: `${thumbWidth}px`,
|
||
transform: `translateX(${thumbLeft}px)`,
|
||
};
|
||
});
|
||
|
||
function setSidebarHorizontalScrollFromPointer(clientX: number, offset: number) {
|
||
const scroller = currentTreeScroller();
|
||
const track = sidebarHorizontalScrollbarTrackRef.value;
|
||
if (!scroller || !track) return;
|
||
|
||
const rect = track.getBoundingClientRect();
|
||
const { maxThumbLeft, maxScrollLeft } = sidebarHorizontalScrollbarGeometry();
|
||
if (maxThumbLeft <= 0) return;
|
||
|
||
const thumbLeft = Math.min(maxThumbLeft, Math.max(0, clientX - rect.left - offset));
|
||
scroller.scrollLeft = (thumbLeft / maxThumbLeft) * maxScrollLeft;
|
||
updateSidebarScrollMetrics();
|
||
}
|
||
|
||
function stopSidebarHorizontalScrollbarDrag() {
|
||
isDraggingSidebarHorizontalScrollbar.value = false;
|
||
window.removeEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove);
|
||
window.removeEventListener("pointerup", stopSidebarHorizontalScrollbarDrag);
|
||
window.removeEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag);
|
||
}
|
||
|
||
function onSidebarHorizontalScrollbarPointerMove(event: PointerEvent) {
|
||
event.preventDefault();
|
||
setSidebarHorizontalScrollFromPointer(event.clientX, sidebarHorizontalScrollbarDragOffset);
|
||
}
|
||
|
||
function onSidebarHorizontalScrollbarTrackPointerDown(event: PointerEvent) {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault();
|
||
const { thumbWidth } = sidebarHorizontalScrollbarGeometry();
|
||
sidebarHorizontalScrollbarDragOffset = thumbWidth / 2;
|
||
setSidebarHorizontalScrollFromPointer(event.clientX, sidebarHorizontalScrollbarDragOffset);
|
||
isDraggingSidebarHorizontalScrollbar.value = true;
|
||
window.addEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove);
|
||
window.addEventListener("pointerup", stopSidebarHorizontalScrollbarDrag);
|
||
window.addEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag);
|
||
}
|
||
|
||
function onSidebarHorizontalScrollbarThumbPointerDown(event: PointerEvent) {
|
||
if (event.button !== 0) return;
|
||
event.preventDefault();
|
||
const track = sidebarHorizontalScrollbarTrackRef.value;
|
||
if (!track) return;
|
||
|
||
const rect = track.getBoundingClientRect();
|
||
const { thumbLeft } = sidebarHorizontalScrollbarGeometry();
|
||
sidebarHorizontalScrollbarDragOffset = event.clientX - rect.left - thumbLeft;
|
||
isDraggingSidebarHorizontalScrollbar.value = true;
|
||
window.addEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove);
|
||
window.addEventListener("pointerup", stopSidebarHorizontalScrollbarDrag);
|
||
window.addEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag);
|
||
}
|
||
|
||
const pasteHandlerRegistry = createSidebarPasteHandlerRegistry();
|
||
|
||
provide(sidebarTreeContextKey, {
|
||
getVisibleNodes: () => selectableVisibleNodes.value,
|
||
getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1,
|
||
setTableSearchQuery: (parentNodeId, query, local) => {
|
||
const focusRestore = captureTableSearchFocus(parentNodeId);
|
||
latestTableSearchInteractionParentId = parentNodeId;
|
||
latestTableSearchInteractionId = focusRestore.interactionId;
|
||
store.setSidebarTableSearchQuery(parentNodeId, query);
|
||
if (local) {
|
||
localTableSearchFocusPending = true;
|
||
void nextTick(() => {
|
||
restoreTableSearchInput(focusRestore);
|
||
localTableSearchFocusPending = false;
|
||
});
|
||
void loadLocalTableSearchResults(parentNodeId, false, focusRestore);
|
||
} else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore });
|
||
},
|
||
refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true),
|
||
registerPasteHandler: pasteHandlerRegistry.register,
|
||
});
|
||
provide(sidebarTreeRuntimeKey, sidebarTreeRuntime);
|
||
|
||
function bindSidebarTreeRuntimeHost(host: Element | ComponentPublicInstance | null) {
|
||
const runtimeHost = host as SidebarTreeRuntimeHostInstance | null;
|
||
sidebarTreeRuntimeHostRef.value = runtimeHost;
|
||
sidebarTreeRuntime.bindHost(runtimeHost);
|
||
}
|
||
|
||
const pendingRenameGroupId = ref<string | null>(null);
|
||
const highlightedNodeId = ref<string | null>(null);
|
||
let highlightTimer: number | undefined;
|
||
|
||
// 等待虚拟列表渲染后再高亮。
|
||
function waitForSidebarRenderFrame(): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
window.requestAnimationFrame(() => resolve());
|
||
});
|
||
}
|
||
|
||
// 重新触发定位高亮,支持连续定位同一节点。
|
||
async function flashSidebarNode(nodeId: string) {
|
||
window.clearTimeout(highlightTimer);
|
||
highlightedNodeId.value = null;
|
||
await nextTick();
|
||
await waitForSidebarRenderFrame();
|
||
|
||
highlightedNodeId.value = nodeId;
|
||
highlightTimer = window.setTimeout(() => {
|
||
if (highlightedNodeId.value === nodeId) highlightedNodeId.value = null;
|
||
}, 1800);
|
||
}
|
||
|
||
function topOcclusionHeightForSidebarNode(nodeId: string): number {
|
||
const sticky = stickyNode.value;
|
||
if (!useVirtualTree.value || !sticky || sticky.id === nodeId) return 0;
|
||
return SIDEBAR_TREE_ROW_HEIGHT;
|
||
}
|
||
|
||
async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNodeScrollAlign }) {
|
||
await nextTick();
|
||
|
||
const index = flatTreeIndex.value.flatNodeIndexById.get(nodeId) ?? -1;
|
||
const scroller = currentTreeScroller();
|
||
if (!scroller || index < 0) return;
|
||
|
||
const nextScrollTop = scrollTopForSidebarNode({
|
||
index,
|
||
currentScrollTop: scroller.scrollTop,
|
||
viewportHeight: scroller.clientHeight,
|
||
topOcclusionHeight: topOcclusionHeightForSidebarNode(nodeId),
|
||
...(options?.align ? { align: options.align } : {}),
|
||
});
|
||
if (nextScrollTop !== scroller.scrollTop) {
|
||
scroller.scrollTop = nextScrollTop;
|
||
}
|
||
}
|
||
|
||
function clearSidebarSelection() {
|
||
// Clicking the blank area of the tree clears the current selection. Row
|
||
// clicks call event.stopPropagation(), so this only fires for blank clicks
|
||
// (issue #681 — selection wasn't cleared in double-click activation mode).
|
||
store.connectionMultiSelectActive = false;
|
||
store.selectedTreeNodeId = null;
|
||
store.selectedTreeNodeIds = [];
|
||
store.treeSelectionAnchorId = null;
|
||
}
|
||
|
||
async function createNewGroup() {
|
||
const groupId = store.createConnectionGroup(t("connectionGroup.newGroupDefault"));
|
||
await startRenamingCreatedGroup(groupId);
|
||
}
|
||
|
||
async function startRenamingCreatedGroup(groupId: string) {
|
||
pendingRenameGroupId.value = groupId;
|
||
store.selectedTreeNodeId = groupId;
|
||
if (isRootListPartial.value) {
|
||
searchQuery.value = "";
|
||
deferredSearchQuery.value = "";
|
||
showConnectedConnectionsOnly.value = false;
|
||
clearSearchScopeFilter();
|
||
}
|
||
|
||
await scrollToSidebarNode(groupId);
|
||
store.selectedTreeNodeId = groupId;
|
||
}
|
||
|
||
async function locateActiveTabInSidebar() {
|
||
const tab = activeTab.value;
|
||
if (!tab) return;
|
||
|
||
const connId = tab.connectionId;
|
||
|
||
// Reconnect if the connection was disconnected (children are cleared on disconnect)
|
||
if (connId && !store.connectedIds.has(connId)) {
|
||
const config = store.getConfig(connId);
|
||
if (!config) return;
|
||
try {
|
||
await store.connect(config);
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const config = connId ? store.getConfig(connId) : undefined;
|
||
const cursorCandidate = queryCursorTableCandidate(tab, effectiveDatabaseTypeForConnection(config));
|
||
const fallbackTarget = queryContextTargetFromCandidate(tab, cursorCandidate) ?? activeTabSidebarTarget(tab);
|
||
const initialTarget = cursorCandidate ? tableTargetFromCandidate(cursorCandidate) : fallbackTarget;
|
||
if (!initialTarget) return;
|
||
|
||
// Ensure the tree is loaded deep enough to contain the preferred target.
|
||
await ensureTreeLoadedForTarget(initialTarget);
|
||
|
||
// Clear any active search filter so the node is visible
|
||
if (isRootListPartial.value) {
|
||
searchQuery.value = "";
|
||
deferredSearchQuery.value = "";
|
||
showConnectedConnectionsOnly.value = false;
|
||
clearSearchScopeFilter();
|
||
}
|
||
|
||
let target = resolveLoadedLocateTarget(initialTarget, cursorCandidate);
|
||
let nodePath = target ? findNodePathForTarget(target, store.treeNodes) : null;
|
||
if (!nodePath) {
|
||
// The first load may have served a stale schema cache whose async refresh
|
||
// replaced the database node before its tables finished loading, so the
|
||
// table isn't in the tree yet. Force a synchronous reload and retry once so
|
||
// locate reaches the table, not just the database (issue #715).
|
||
await ensureTreeLoadedForTarget(initialTarget, { force: true });
|
||
target = resolveLoadedLocateTarget(initialTarget, cursorCandidate);
|
||
nodePath = target ? findNodePathForTarget(target, store.treeNodes) : null;
|
||
}
|
||
|
||
if (!nodePath && cursorCandidate) {
|
||
await store.loadTableForLocate(cursorCandidate);
|
||
target = resolveLoadedLocateTarget(initialTarget, cursorCandidate);
|
||
nodePath = target ? findNodePathForTarget(target, store.treeNodes) : null;
|
||
}
|
||
|
||
if (!nodePath && cursorCandidate && fallbackTarget) {
|
||
await ensureTreeLoadedForTarget(fallbackTarget);
|
||
target = fallbackTarget;
|
||
nodePath = findNodePathForTarget(fallbackTarget, store.treeNodes);
|
||
}
|
||
|
||
if (!nodePath) return;
|
||
|
||
for (const ancestor of nodePath) {
|
||
if (!ancestor.isExpanded) {
|
||
ancestor.isExpanded = true;
|
||
}
|
||
}
|
||
|
||
await nextTick();
|
||
|
||
const match = target ? findSidebarNodeForTarget(target, flatNodes.value) : null;
|
||
if (!match) return;
|
||
|
||
store.selectedTreeNodeId = match.id;
|
||
store.selectedTreeNodeIds = [match.id];
|
||
store.treeSelectionAnchorId = match.id;
|
||
await nextTick();
|
||
|
||
await scrollToSidebarNode(match.id, { align: "smart" });
|
||
await flashSidebarNode(match.id);
|
||
}
|
||
|
||
function tableTargetFromCandidate(candidate: QueryCursorTableCandidate): ActiveTabSidebarTarget {
|
||
return {
|
||
type: "table",
|
||
connectionId: candidate.connectionId,
|
||
database: candidate.database,
|
||
schema: candidate.schema,
|
||
tableName: candidate.tableName,
|
||
};
|
||
}
|
||
|
||
function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: QueryCursorTableCandidate | null): ActiveTabSidebarTarget | null {
|
||
if (!candidate) return target;
|
||
return findLoadedTableTargetForCandidate(store.treeNodes, candidate);
|
||
}
|
||
|
||
async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) {
|
||
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "etcd-access-control" || target.type === "zookeeper-root") return;
|
||
const connId = target.connectionId;
|
||
if (!connId) return;
|
||
|
||
const config = store.getConfig(connId);
|
||
if (!config) return;
|
||
|
||
// When forcing, bypass the cached children check so we reload from the
|
||
// source. A stale schema cache otherwise serves children and triggers an
|
||
// async background refresh that can replace nodes mid-flight, leaving the
|
||
// tree without the target table by the time we search for it (issue #715).
|
||
const force = opts?.force ?? false;
|
||
const loadOptions = force ? { force: true } : undefined;
|
||
|
||
// Ensure databases are loaded under the connection
|
||
const connNode = store.treeNodes.find((n) => n.id === connId);
|
||
if (connNode && (force || !connNode.children || connNode.children.length === 0)) {
|
||
try {
|
||
if (config.db_type === "redis") {
|
||
await store.loadRedisDatabases(connId);
|
||
} else if (config.db_type === "mongodb") {
|
||
await store.loadMongoDatabases(connId);
|
||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
|
||
await store.loadElasticsearchIndices(connId);
|
||
} else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") {
|
||
await store.loadVectorCollections(connId);
|
||
} else if (config.db_type === "mq") {
|
||
await store.loadMqTenants(connId, loadOptions);
|
||
} else if (config.db_type === "nacos") {
|
||
await store.loadNacosNamespaces(connId, loadOptions);
|
||
} else {
|
||
await store.loadDatabases(connId, loadOptions);
|
||
}
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (config.db_type === "mq" || config.db_type === "nacos") return;
|
||
if (!("database" in target) || !target.database) return;
|
||
|
||
// Find the database node
|
||
const dbNode = findDatabaseNode(store.treeNodes, connId, target.database);
|
||
if (!dbNode) return;
|
||
const targetSchema = "schema" in target ? target.schema : undefined;
|
||
const databaseChildrenLoaded = !!dbNode.children && dbNode.children.length > 0;
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||
const usesSchemaTree = usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config);
|
||
const shouldLoadSchemaTables = target.type === "table" && !!targetSchema && usesSchemaTree;
|
||
if (!force && databaseChildrenLoaded && !shouldLoadSchemaTables) return;
|
||
|
||
// Load database contents
|
||
try {
|
||
if (config.db_type === "sqlserver") {
|
||
if (force || !databaseChildrenLoaded) {
|
||
await store.loadSqlServerDatabaseObjects(connId, target.database, loadOptions);
|
||
}
|
||
if (targetSchema) {
|
||
const schemaNode = findSchemaNode(store.treeNodes, connId, target.database, targetSchema);
|
||
if (schemaNode && (force || !schemaNode.children || schemaNode.children.length === 0)) {
|
||
await store.loadTables(connId, target.database, targetSchema, loadOptions);
|
||
}
|
||
}
|
||
} else if (usesSchemaTree) {
|
||
if (force || !databaseChildrenLoaded) {
|
||
await store.loadSchemas(connId, target.database, loadOptions);
|
||
}
|
||
// If we have a schema, also load tables under that schema
|
||
if (targetSchema) {
|
||
const schemaNode = findSchemaNode(store.treeNodes, connId, target.database, targetSchema);
|
||
if (schemaNode && (force || !schemaNode.children || schemaNode.children.length === 0)) {
|
||
await store.loadTables(connId, target.database, targetSchema, loadOptions);
|
||
}
|
||
}
|
||
} else {
|
||
await store.loadTables(connId, target.database, undefined, loadOptions);
|
||
}
|
||
|
||
if (target.type === "table") {
|
||
await ensureTableObjectGroupsLoaded(target, loadOptions);
|
||
}
|
||
} catch {
|
||
// Node just won't have children loaded
|
||
}
|
||
}
|
||
|
||
async function ensureTableObjectGroupsLoaded(target: Extract<ActiveTabSidebarTarget, { type: "table" }>, options?: { force?: boolean }) {
|
||
const groups = findTableObjectGroupNodes(store.treeNodes, target);
|
||
for (const group of groups) {
|
||
if (!options?.force && group.children && group.children.length > 0) continue;
|
||
await store.loadObjectGroupChildren(group, options);
|
||
}
|
||
}
|
||
|
||
function findTableObjectGroupNodes(nodes: TreeNode[], target: Extract<ActiveTabSidebarTarget, { type: "table" }>): TreeNode[] {
|
||
const matches: TreeNode[] = [];
|
||
for (const node of nodes) {
|
||
if ((node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views") && node.connectionId === target.connectionId && sameTreeName(node.database, target.database) && (!target.schema || sameTreeName(node.schema, target.schema))) {
|
||
matches.push(node);
|
||
}
|
||
if (node.children) {
|
||
matches.push(...findTableObjectGroupNodes(node.children, target));
|
||
}
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
function sameTreeName(left: string | undefined, right: string | undefined): boolean {
|
||
return (left || "").toLowerCase() === (right || "").toLowerCase();
|
||
}
|
||
|
||
function findDatabaseNode(nodes: TreeNode[], connId: string, database: string): TreeNode | null {
|
||
for (const node of nodes) {
|
||
if (node.type === "database" && node.connectionId === connId && sameTreeName(node.database, database)) {
|
||
return node;
|
||
}
|
||
if (node.children) {
|
||
const found = findDatabaseNode(node.children, connId, database);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function findSchemaNode(nodes: TreeNode[], connId: string, database: string, schema: string): TreeNode | null {
|
||
for (const node of nodes) {
|
||
if (node.type === "schema" && node.connectionId === connId && sameTreeName(node.database, database) && sameTreeName(node.label, schema)) {
|
||
return node;
|
||
}
|
||
if (node.children) {
|
||
const found = findSchemaNode(node.children, connId, database, schema);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function onSearchToggle(node: TreeNode) {
|
||
if (!isTreeSearchFiltering.value || !node.children) return;
|
||
const next = new Set(searchCollapsedIds.value);
|
||
if (node.isExpanded) next.add(node.id);
|
||
else next.delete(node.id);
|
||
searchCollapsedIds.value = next;
|
||
}
|
||
|
||
function onNodeToggled(node: TreeNode, expanded: boolean) {
|
||
if (isTreeSearchFiltering.value) return;
|
||
syncSidebarTreeNodeExpansion(store.treeNodes, node, expanded);
|
||
}
|
||
|
||
function openSidebarContextMenu(event: MouseEvent, node: TreeNode, openContextMenu: (event: MouseEvent, itemsOverride?: ContextMenuItem[]) => void) {
|
||
const items = sidebarTreeRuntime.buildContextMenu(node);
|
||
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();
|
||
}
|
||
|
||
async function openSidebarExtensionDetails(node: TreeNode) {
|
||
sidebarExtensionDetailsTarget.value = createSidebarActionTarget(node);
|
||
await nextTick();
|
||
sidebarExtensionDetailsDialogRef.value?.show();
|
||
}
|
||
|
||
function beginSidebarAction(): number {
|
||
sidebarActionGeneration += 1;
|
||
sidebarDdlOpen.value = false;
|
||
sidebarObjectSourceOpen.value = false;
|
||
sidebarProcedureOpen.value = false;
|
||
sidebarVisibleDatabasesOpen.value = false;
|
||
sidebarVisibleSchemasOpen.value = false;
|
||
sidebarVisibleNacosNamespacesOpen.value = false;
|
||
sidebarTableNameFilterOpen.value = false;
|
||
sidebarDdlTarget.value = null;
|
||
sidebarObjectSourceTarget.value = null;
|
||
sidebarProcedureTarget.value = null;
|
||
sidebarVisibleDatabasesTarget.value = null;
|
||
sidebarVisibleSchemasTarget.value = null;
|
||
sidebarVisibleNacosNamespacesTarget.value = null;
|
||
sidebarTableNameFilterTarget.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 openSidebarDdlForSelection(): boolean {
|
||
const selectedNodeId = store.selectedTreeNodeId;
|
||
const node = selectedNodeId ? flatTreeIndex.value.nodeById.get(selectedNodeId) : null;
|
||
if (!node || !sidebarNodeSupportsDdlView(node)) return false;
|
||
openSidebarDdl(node);
|
||
return 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 openSidebarSettings(initialTab: string) {
|
||
emit("open-settings", initialTab);
|
||
}
|
||
|
||
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, openMode: "default" | "new-tab", runner: (node: TreeNode, request: SidebarDataOpenRequest) => Promise<void>) {
|
||
const target = createSidebarActionTarget(node);
|
||
runSidebarDataOpenImmediately(
|
||
{
|
||
connectionKey: target.connectionId || target.id,
|
||
// Explicit new-tab opens are intentional independent work; ordinary
|
||
// navigation keeps latest-request-wins behavior.
|
||
supersede: openMode !== "new-tab",
|
||
},
|
||
(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 openSidebarVisibleNacosNamespaces(node: TreeNode) {
|
||
if (node.type !== "connection" || !node.connectionId || store.getConfig(node.connectionId)?.db_type !== "nacos") return;
|
||
beginSidebarAction();
|
||
sidebarVisibleNacosNamespacesTarget.value = createSidebarActionTarget(node);
|
||
sidebarVisibleNacosNamespacesOpen.value = true;
|
||
}
|
||
|
||
function tableNameFilterScopeForNode(node: TreeNode): string | null {
|
||
if (!node.connectionId || !node.database) return null;
|
||
return store.tableNameFilterScopeKey({
|
||
connectionId: node.connectionId,
|
||
database: node.database,
|
||
schema: node.schema,
|
||
nodeKind: node.type,
|
||
catalog: node.catalog,
|
||
});
|
||
}
|
||
|
||
function patternsFromDraft(value: string): string[] {
|
||
return value
|
||
.split(/\r?\n/)
|
||
.map((pattern) => pattern.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function openSidebarTableNameFilters(node: TreeNode) {
|
||
const scopeKey = tableNameFilterScopeForNode(node);
|
||
if (!scopeKey) return;
|
||
beginSidebarAction();
|
||
sidebarTableNameFilterTarget.value = createSidebarActionTarget(node);
|
||
const filter = store.sidebarTableNameFilters[scopeKey];
|
||
tableNameFilterIncludeDraft.value = filter?.includePatterns.join("\n") ?? "";
|
||
tableNameFilterExcludeDraft.value = filter?.excludePatterns.join("\n") ?? "";
|
||
sidebarTableNameFilterOpen.value = true;
|
||
}
|
||
|
||
async function saveSidebarTableNameFilters() {
|
||
const target = sidebarTableNameFilterTarget.value;
|
||
if (!target) return;
|
||
const scopeKey = tableNameFilterScopeForNode(target);
|
||
if (!scopeKey) return;
|
||
const filter: TableNameFilter = {
|
||
includePatterns: patternsFromDraft(tableNameFilterIncludeDraft.value),
|
||
excludePatterns: patternsFromDraft(tableNameFilterExcludeDraft.value),
|
||
};
|
||
const revision = store.setSidebarTableNameFilter(scopeKey, filter);
|
||
sidebarTableNameFilterOpen.value = false;
|
||
const currentTarget = findSidebarActionTarget(store.treeNodes, target);
|
||
if (currentTarget) {
|
||
try {
|
||
await store.refreshTreeNodeForTableNameFilter(currentTarget, scopeKey, revision);
|
||
} catch (error: any) {
|
||
toast(error?.message || String(error), 5000);
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearSidebarTableNameFilters() {
|
||
tableNameFilterIncludeDraft.value = "";
|
||
tableNameFilterExcludeDraft.value = "";
|
||
}
|
||
|
||
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, undefined, target.catalog);
|
||
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, undefined, target.catalog);
|
||
queryStore.updateSql(tabId, sql);
|
||
await queryStore.executeTabSql(tabId, sql);
|
||
}
|
||
|
||
async function refreshSidebarActionTarget() {
|
||
const target = sidebarObjectSourceTarget.value?.node || sidebarDdlTarget.value || sidebarInstallExtensionTarget.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;
|
||
});
|
||
|
||
watch(sidebarVisibleNacosNamespacesOpen, (open) => {
|
||
if (!open) sidebarVisibleNacosNamespacesTarget.value = null;
|
||
});
|
||
|
||
watch(sidebarTableNameFilterOpen, (open) => {
|
||
if (!open) sidebarTableNameFilterTarget.value = null;
|
||
});
|
||
|
||
function collapseAllTreeNodes() {
|
||
store.collapseAllTreeNodes();
|
||
// 与 onSearchToggle 一致:scope-only 过滤也要填充 searchCollapsedIds,
|
||
// 否则 filteredNodes 会用空集合把所有分组重建成展开态,“全部折叠”空操作。
|
||
if (isTreeSearchFiltering.value) {
|
||
searchCollapsedIds.value = new Set(flatTreeIndex.value.expandableNodeIds);
|
||
}
|
||
}
|
||
|
||
function currentTreeScroller(): HTMLElement | null {
|
||
return ((useVirtualTree.value ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined) ?? null;
|
||
}
|
||
|
||
async function selectActiveTabSidebarNode(options: { scroll: boolean }) {
|
||
if (!settingsStore.editorSettings.autoSelectActiveSidebarNode) return;
|
||
const match = findSidebarNodeForActiveTab(activeTab.value, flatNodes.value);
|
||
if (!match) return;
|
||
|
||
store.selectedTreeNodeId = match.id;
|
||
if (!options.scroll) return;
|
||
|
||
await nextTick();
|
||
|
||
const index = flatTreeIndex.value.flatNodeIndexById.get(match.id) ?? -1;
|
||
const scroller = currentTreeScroller();
|
||
if (!scroller || index < 0) return;
|
||
|
||
const nextScrollTop = scrollTopForSidebarNode({
|
||
index,
|
||
currentScrollTop: scroller.scrollTop,
|
||
viewportHeight: scroller.clientHeight,
|
||
topOcclusionHeight: topOcclusionHeightForSidebarNode(match.id),
|
||
});
|
||
if (nextScrollTop !== scroller.scrollTop) {
|
||
scroller.scrollTop = nextScrollTop;
|
||
}
|
||
}
|
||
|
||
watch(
|
||
[() => activeTab.value?.id ?? null, flatNodes, () => settingsStore.editorSettings.autoSelectActiveSidebarNode],
|
||
([activeTabId, _nodes, autoSelectEnabled], [previousActiveTabId, _previousNodes, previousAutoSelectEnabled]) => {
|
||
void selectActiveTabSidebarNode({
|
||
scroll: shouldScrollActiveSidebarSelection({
|
||
activeTabId,
|
||
previousActiveTabId,
|
||
autoSelectEnabled,
|
||
previousAutoSelectEnabled,
|
||
}),
|
||
});
|
||
},
|
||
{ flush: "post" },
|
||
);
|
||
|
||
function focusSearch(): boolean {
|
||
const input = searchInputRef.value;
|
||
if (!input) return false;
|
||
input.focus();
|
||
input.select();
|
||
return true;
|
||
}
|
||
|
||
function onSearchKeydown(event: KeyboardEvent) {
|
||
if (!isCancelSearchShortcut(event)) return;
|
||
event.preventDefault();
|
||
searchQuery.value = "";
|
||
}
|
||
|
||
function focusSearchAtEnd() {
|
||
nextTick(() => {
|
||
const input = searchInputRef.value;
|
||
if (!input) return;
|
||
input.focus();
|
||
const end = input.value.length;
|
||
input.setSelectionRange(end, end);
|
||
});
|
||
}
|
||
|
||
function onWindowKeydown(event: KeyboardEvent) {
|
||
if (event.defaultPrevented) return;
|
||
if (localTableSearchFocusPending) return;
|
||
if (sidebarShortcutTargetIsActive(event.target)) {
|
||
if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isEditConnectionShortcut(event)) {
|
||
if (requestSelectedConnectionEdit()) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
return;
|
||
}
|
||
if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isCopySidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts)) {
|
||
if (copySelectedSidebarNames()) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
return;
|
||
}
|
||
if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isPasteSidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts)) {
|
||
if (requestSelectedSidebarPaste()) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
return;
|
||
}
|
||
if (sidebarShortcutTargetAllowsAppShortcut(event.target) && isViewTableDdlShortcut(event, settingsStore.editorSettings.shortcuts)) {
|
||
if (openSidebarDdlForSelection()) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (!pointerInsideTree.value || isEditableSidebarTypeSearchTarget(event.target) || isEditableSidebarTypeSearchTarget(document.activeElement)) return;
|
||
if (isCancelSearchShortcut(event)) {
|
||
if (!searchQuery.value) return;
|
||
event.preventDefault();
|
||
searchQuery.value = "";
|
||
focusSearchAtEnd();
|
||
return;
|
||
}
|
||
const nextQuery = sidebarTypeSearchNextQuery(searchQuery.value, event);
|
||
if (nextQuery == null) return;
|
||
event.preventDefault();
|
||
searchQuery.value = nextQuery;
|
||
focusSearchAtEnd();
|
||
}
|
||
|
||
function sidebarShortcutTargetIsActive(target: EventTarget | null): boolean {
|
||
const root = rootRef.value;
|
||
if (!root) return false;
|
||
if (target instanceof Node && root.contains(target)) return true;
|
||
const active = document.activeElement;
|
||
return pointerInsideTree.value && (!active || active === document.body || root.contains(active));
|
||
}
|
||
|
||
function sidebarShortcutTargetAllowsAppShortcut(target: EventTarget | null): boolean {
|
||
if (!(target instanceof HTMLElement)) return true;
|
||
return !(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable || !!target.closest("[contenteditable='true'], [role='textbox']"));
|
||
}
|
||
|
||
function selectedSidebarNodesInVisibleOrder(): TreeNode[] {
|
||
const selectedIds = new Set(store.selectedTreeNodeIds);
|
||
return visibleNodes.value.filter((node) => selectedIds.has(node.id));
|
||
}
|
||
|
||
function isEditConnectionShortcut(event: KeyboardEvent): boolean {
|
||
return isEditSidebarConnectionShortcut(event, settingsStore.editorSettings.shortcuts);
|
||
}
|
||
|
||
function requestSelectedConnectionEdit(): boolean {
|
||
const selectedNodeId = store.selectedTreeNodeId;
|
||
const currentNode = selectedNodeId ? flatTreeIndex.value.nodeById.get(selectedNodeId) : null;
|
||
if (!currentNode) return false;
|
||
const editTarget = selectedConnectionEditTarget(currentNode, selectedSidebarNodesInVisibleOrder());
|
||
if (!editTarget) return false;
|
||
store.startEditing(editTarget.connectionId);
|
||
return true;
|
||
}
|
||
|
||
function copySelectedSidebarNames(): boolean {
|
||
const nodes = selectedSidebarNodesInVisibleOrder();
|
||
if (nodes.length === 0) return false;
|
||
const copiedCount = copySelectedConnectionsToClipboards(nodes, (connectionIds) => store.copyConnectionsToTreeClipboard(connectionIds), copyToClipboard);
|
||
if (copiedCount > 0) {
|
||
toast(t("connection.copied"), 2000);
|
||
return true;
|
||
}
|
||
const tableNodes = nodes.filter((node) => node.type === "table" && !!node.connectionId && !!node.database);
|
||
store.treeClipboard =
|
||
tableNodes.length > 0
|
||
? {
|
||
kind: "table-copy",
|
||
tables: tableNodes.map((node) => ({
|
||
connectionId: node.connectionId!,
|
||
database: node.database!,
|
||
schema: connectionObjectTreeNodeSchema(store.getConfig(node.connectionId!), node.database!, node.schema),
|
||
tableName: node.label,
|
||
tableComment: node.comment,
|
||
})),
|
||
}
|
||
: null;
|
||
copyToClipboard(nodes.map(copyNameForTreeNode).join("\n"))
|
||
.then(() => toast(t("connection.copied"), 2000))
|
||
.catch((e: any) => toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000));
|
||
return true;
|
||
}
|
||
|
||
function requestSelectedSidebarPaste(): boolean {
|
||
const clipboard = store.treeClipboard;
|
||
const selectedNodeId = store.selectedTreeNodeId;
|
||
if (clipboard?.kind === "connection-copy") {
|
||
const selectedNode = selectedNodeId ? flatTreeIndex.value.nodeById.get(selectedNodeId) : null;
|
||
const targetGroupId = connectionPasteTargetGroupId(selectedNode, (connectionId) => store.groupIdForConnection(connectionId));
|
||
void store
|
||
.pasteConnectionClipboard(targetGroupId)
|
||
.then((count) => {
|
||
if (count > 0) toast(count > 1 ? t("connection.duplicatedSelected", { count }) : t("connection.duplicated"), 2000);
|
||
})
|
||
.catch((e: any) => toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000));
|
||
return true;
|
||
}
|
||
if (clipboard?.kind !== "table-copy" || clipboard.tables.length === 0 || !selectedNodeId) return false;
|
||
|
||
return pasteHandlerRegistry.request(selectedNodeId);
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener("keydown", onWindowKeydown);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
sidebarTreeRuntime.dispose();
|
||
sidebarActionGeneration += 1;
|
||
sidebarContextMenuTarget.value = null;
|
||
sidebarContextMenuItems.value = [];
|
||
sidebarDdlTarget.value = null;
|
||
sidebarObjectSourceTarget.value = null;
|
||
sidebarProcedureTarget.value = null;
|
||
sidebarVisibleDatabasesTarget.value = null;
|
||
sidebarVisibleSchemasTarget.value = null;
|
||
sidebarVisibleNacosNamespacesTarget.value = null;
|
||
sidebarTreeItemDialogController.value = null;
|
||
sidebarDangerDialogRequest.value = null;
|
||
resetSidebarTreeDialogState();
|
||
window.removeEventListener("keydown", onWindowKeydown);
|
||
cancelPendingSidebarDataOpen();
|
||
for (const timer of tableSearchTimers.values()) {
|
||
window.clearTimeout(timer);
|
||
}
|
||
tableSearchTimers.clear();
|
||
tableSearchFocusRestoreTokens.clear();
|
||
latestTableSearchInteractionParentId = null;
|
||
latestTableSearchInteractionId = 0;
|
||
stopSidebarScrollbarDrag();
|
||
stopSidebarHorizontalScrollbarDrag();
|
||
sidebarScrollbarResizeObserver?.disconnect();
|
||
window.cancelAnimationFrame(sidebarScrollbarAnimationFrame);
|
||
window.clearTimeout(sidebarScrollingTimer);
|
||
if (sidebarCommentMeasureFrame) window.cancelAnimationFrame(sidebarCommentMeasureFrame);
|
||
if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame);
|
||
});
|
||
|
||
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="rootRef" class="h-full min-h-0 flex flex-col text-sm select-none" @pointerenter="pointerInsideTree = true" @pointerleave="pointerInsideTree = false">
|
||
<SidebarTreeRuntimeHost
|
||
:ref="bindSidebarTreeRuntimeHost"
|
||
:node="sidebarTreeRuntimeInitialNode"
|
||
:depth="0"
|
||
@search-toggle="onSearchToggle"
|
||
@node-toggled="onNodeToggled"
|
||
@open-ddl="openSidebarDdl"
|
||
@open-object-source="openSidebarObjectSource"
|
||
@open-procedure="openSidebarProcedure"
|
||
@open-settings="openSidebarSettings"
|
||
@open-data="openSidebarData"
|
||
@open-visible-databases="openSidebarVisibleDatabases"
|
||
@open-visible-schemas="openSidebarVisibleSchemas"
|
||
@open-visible-nacos-namespaces="openSidebarVisibleNacosNamespaces"
|
||
@open-table-name-filters="openSidebarTableNameFilters"
|
||
@request-group-rename="startRenamingCreatedGroup"
|
||
@open-danger-dialog="openSidebarDangerDialog"
|
||
@open-dialog-controller="updateSidebarTreeItemDialogController"
|
||
@open-install-extension="openSidebarInstallExtension"
|
||
@open-extension-details="openSidebarExtensionDetails"
|
||
/>
|
||
<div class="connection-tree-search sticky top-0 z-10 bg-background px-2 py-1">
|
||
<div class="relative flex items-center gap-1">
|
||
<div class="relative flex-1">
|
||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||
<input
|
||
ref="searchInputRef"
|
||
v-model="searchQuery"
|
||
autocapitalize="off"
|
||
autocorrect="off"
|
||
spellcheck="false"
|
||
class="w-full h-6 pl-7 pr-6 text-xs rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
|
||
:placeholder="t('grid.search')"
|
||
@keydown="onSearchKeydown"
|
||
/>
|
||
<button v-if="searchQuery" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="searchQuery = ''">
|
||
<X class="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
<LightTooltip :text="t('sidebar.globalLocalSearchTooltip')" side="top" :delay="300">
|
||
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarGlobalSearchLocal" :aria-label="t('sidebar.globalLocalSearch')" @update:model-value="settingsStore.updateEditorSettings({ sidebarGlobalSearchLocal: Boolean($event) })" />
|
||
</LightTooltip>
|
||
<LightTooltip :text="t('sidebar.locateActiveTab')" side="top" :delay="300" nowrap>
|
||
<button type="button" class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground" :aria-label="t('sidebar.locateActiveTab')" @click="locateActiveTabInSidebar">
|
||
<Crosshair class="h-3.5 w-3.5" />
|
||
</button>
|
||
</LightTooltip>
|
||
<LightTooltip :text="t('sidebar.sortConnections')" side="top" :delay="300" nowrap>
|
||
<span class="inline-flex">
|
||
<LightDropdown
|
||
:model-value="settingsStore.editorSettings.sidebarConnectionSortMode"
|
||
:items="connectionListSortMenuItems"
|
||
:aria-label="t('sidebar.sortConnections')"
|
||
:label="t('sidebar.sortConnections')"
|
||
:trigger-class="['shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent', isConnectionListAlphabeticallySorted ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'].join(' ')"
|
||
trigger-icon-class="h-3.5 w-3.5"
|
||
item-icon-class="h-3.5 w-3.5"
|
||
content-class="w-max min-w-0"
|
||
selected-item-class="bg-primary/10 text-primary"
|
||
selected-check-class="text-primary"
|
||
:show-trigger-label="false"
|
||
:show-chevron="false"
|
||
align="end"
|
||
@update:model-value="updateConnectionListSortMode"
|
||
/>
|
||
</span>
|
||
</LightTooltip>
|
||
<LightTooltip v-if="searchScopeOptions.length > 0" :text="t('sidebar.filterByType')" side="top" :delay="300" nowrap>
|
||
<span class="inline-flex">
|
||
<LightDropdown
|
||
model-value=""
|
||
:items="searchScopeMenuItems"
|
||
:selected-values="selectedSearchScopes"
|
||
:aria-label="t('sidebar.filterByType')"
|
||
:label="t('sidebar.filterByType')"
|
||
:trigger-icon="ListFilter"
|
||
:trigger-class="['shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent', hasSearchScopeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'].join(' ')"
|
||
trigger-icon-class="h-3.5 w-3.5"
|
||
item-icon-class="h-3.5 w-3.5"
|
||
content-class="w-max min-w-0"
|
||
selected-item-class="bg-primary/10 text-primary"
|
||
selected-check-class="text-primary"
|
||
:show-trigger-label="false"
|
||
:show-chevron="false"
|
||
:close-on-select="false"
|
||
align="end"
|
||
@update:model-value="selectSearchScopeMenuItem"
|
||
/>
|
||
</span>
|
||
</LightTooltip>
|
||
<LightTooltip :text="t('sidebar.showActiveConnectionsOnly')" side="top" :delay="300" nowrap>
|
||
<button
|
||
type="button"
|
||
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border hover:bg-accent"
|
||
:class="showConnectedConnectionsOnly ? 'text-primary bg-primary/10 border-primary/30' : 'border-border text-muted-foreground hover:text-foreground'"
|
||
:aria-label="t('sidebar.showActiveConnectionsOnly')"
|
||
:aria-pressed="showConnectedConnectionsOnly"
|
||
@click="showConnectedConnectionsOnly = !showConnectedConnectionsOnly"
|
||
>
|
||
<CircleDot class="h-3.5 w-3.5" />
|
||
</button>
|
||
</LightTooltip>
|
||
</div>
|
||
</div>
|
||
<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" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
|
||
<RecycleScroller
|
||
ref="treeScrollerRef"
|
||
class="sidebar-tree connection-tree-scroller h-full overflow-y-auto"
|
||
:class="sidebarTreeOverflowClass"
|
||
:style="sidebarTreeScrollerStyle"
|
||
@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"
|
||
list-class="connection-tree-content"
|
||
flow-mode
|
||
>
|
||
<template #default="{ item }">
|
||
<TreeItem
|
||
:node="item.node"
|
||
:depth="item.depth"
|
||
:reorder-disabled="isRootListPartial || isConnectionListAlphabeticallySorted"
|
||
:pending-rename="pendingRenameGroupId === item.node.id"
|
||
:highlighted="highlightedNodeId === item.node.id"
|
||
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
|
||
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
|
||
@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"
|
||
:reorder-disabled="true"
|
||
:reference-drag-disabled="true"
|
||
:comment-label-width="sidebarCommentLabelWidths.get(stickyNode.node.id)"
|
||
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
|
||
/>
|
||
</div>
|
||
<div
|
||
v-if="hasSidebarVerticalOverflow"
|
||
ref="sidebarScrollbarTrackRef"
|
||
class="sidebar-tree-scrollbar"
|
||
:class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar, 'sidebar-tree-scrollbar--with-horizontal': hasSidebarHorizontalOverflow }"
|
||
@pointerdown="onSidebarScrollbarTrackPointerDown"
|
||
>
|
||
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
|
||
</div>
|
||
<div
|
||
v-if="hasSidebarHorizontalOverflow"
|
||
ref="sidebarHorizontalScrollbarTrackRef"
|
||
class="sidebar-tree-horizontal-scrollbar"
|
||
:class="{ 'sidebar-tree-horizontal-scrollbar--with-vertical': hasSidebarVerticalOverflow, 'sidebar-tree-horizontal-scrollbar--dragging': isDraggingSidebarHorizontalScrollbar }"
|
||
@pointerdown="onSidebarHorizontalScrollbarTrackPointerDown"
|
||
>
|
||
<div class="sidebar-tree-horizontal-scrollbar__thumb" :style="sidebarHorizontalScrollbarThumbStyle" @pointerdown.stop="onSidebarHorizontalScrollbarThumbPointerDown" />
|
||
</div>
|
||
</div>
|
||
<div v-else-if="flatNodes.length > 0" class="connection-tree-scroll-shell relative min-h-0 flex-1" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
|
||
<div ref="plainTreeScrollerRef" class="sidebar-tree connection-tree-scroller h-full overflow-y-auto" :class="sidebarTreeOverflowClass" :style="sidebarTreeScrollerStyle" @click="clearSidebarSelection" @scroll.passive="onTreeScroll">
|
||
<div class="connection-tree-content">
|
||
<TreeItem
|
||
v-for="item in flatNodes"
|
||
:key="item.id"
|
||
:node="item.node"
|
||
:depth="item.depth"
|
||
:reorder-disabled="isRootListPartial || isConnectionListAlphabeticallySorted"
|
||
:pending-rename="pendingRenameGroupId === item.node.id"
|
||
:highlighted="highlightedNodeId === item.id"
|
||
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
|
||
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
|
||
@rename-started="pendingRenameGroupId = null"
|
||
@group-created="startRenamingCreatedGroup"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="hasSidebarVerticalOverflow"
|
||
ref="sidebarScrollbarTrackRef"
|
||
class="sidebar-tree-scrollbar"
|
||
:class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar, 'sidebar-tree-scrollbar--with-horizontal': hasSidebarHorizontalOverflow }"
|
||
@pointerdown="onSidebarScrollbarTrackPointerDown"
|
||
>
|
||
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
|
||
</div>
|
||
<div
|
||
v-if="hasSidebarHorizontalOverflow"
|
||
ref="sidebarHorizontalScrollbarTrackRef"
|
||
class="sidebar-tree-horizontal-scrollbar"
|
||
:class="{ 'sidebar-tree-horizontal-scrollbar--with-vertical': hasSidebarVerticalOverflow, 'sidebar-tree-horizontal-scrollbar--dragging': isDraggingSidebarHorizontalScrollbar }"
|
||
@pointerdown="onSidebarHorizontalScrollbarTrackPointerDown"
|
||
>
|
||
<div class="sidebar-tree-horizontal-scrollbar__thumb" :style="sidebarHorizontalScrollbarThumbStyle" @pointerdown.stop="onSidebarHorizontalScrollbarThumbPointerDown" />
|
||
</div>
|
||
</div>
|
||
</CustomContextMenu>
|
||
<SidebarDdlViewDialog
|
||
v-if="sidebarDdlTarget"
|
||
v-model:open="sidebarDdlOpen"
|
||
:connection-id="sidebarDdlTarget.connectionId!"
|
||
:database="sidebarDdlTarget.database!"
|
||
:catalog="sidebarDdlTarget.catalog"
|
||
: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"
|
||
:relation-name="sidebarObjectSourceTarget.node.tableName"
|
||
: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"
|
||
/>
|
||
|
||
<SidebarVisibleNacosNamespacesDialog v-if="sidebarVisibleNacosNamespacesTarget?.connectionId" v-model:open="sidebarVisibleNacosNamespacesOpen" :connection-id="sidebarVisibleNacosNamespacesTarget.connectionId" :connection-name="sidebarVisibleNacosNamespacesTarget.label" />
|
||
<Dialog v-model:open="sidebarTableNameFilterOpen">
|
||
<DialogContent class="max-w-xl">
|
||
<DialogHeader class="space-y-2">
|
||
<DialogTitle>{{ t("contextMenu.tableNameFilters") }}</DialogTitle>
|
||
<DialogDescription>
|
||
{{ t("contextMenu.tableNameFiltersDescription") }}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div class="space-y-5 py-1">
|
||
<div class="rounded-lg border bg-muted/20 p-3.5">
|
||
<label class="mb-2.5 block text-sm font-medium leading-none">{{ t("contextMenu.tableNameFilterInclude") }}</label>
|
||
<textarea
|
||
v-model="tableNameFilterIncludeDraft"
|
||
class="min-h-32 w-full resize-y rounded-md border bg-background px-3 py-2.5 font-mono text-xs leading-relaxed shadow-sm focus:outline-none focus:ring-2 focus:ring-ring/40"
|
||
:placeholder="t('contextMenu.tableNameFilterIncludePlaceholder')"
|
||
></textarea>
|
||
</div>
|
||
<div class="rounded-lg border bg-muted/20 p-3.5">
|
||
<label class="mb-2.5 block text-sm font-medium leading-none">{{ t("contextMenu.tableNameFilterExclude") }}</label>
|
||
<textarea
|
||
v-model="tableNameFilterExcludeDraft"
|
||
class="min-h-32 w-full resize-y rounded-md border bg-background px-3 py-2.5 font-mono text-xs leading-relaxed shadow-sm focus:outline-none focus:ring-2 focus:ring-ring/40"
|
||
:placeholder="t('contextMenu.tableNameFilterExcludePlaceholder')"
|
||
></textarea>
|
||
</div>
|
||
<p class="rounded-md bg-muted/50 px-3 py-2 text-xs leading-relaxed text-muted-foreground">{{ t("contextMenu.tableNameFilterLikeHint") }}</p>
|
||
</div>
|
||
<DialogFooter class="gap-2 sm:justify-between">
|
||
<Button variant="ghost" @click="clearSidebarTableNameFilters">{{ t("common.clear") }}</Button>
|
||
<div class="flex gap-2">
|
||
<Button variant="outline" @click="sidebarTableNameFilterOpen = false">{{ t("dangerDialog.cancel") }}</Button>
|
||
<Button @click="saveSidebarTableNameFilters">{{ t("common.save") }}</Button>
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
<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 #options>
|
||
<div v-if="sidebarDangerDialogConfirming && sidebarDangerDialogRequest.progress" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
|
||
<div class="mb-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
|
||
<span>{{ sidebarDangerDialogRequest.progress.completed }} / {{ sidebarDangerDialogRequest.progress.total }}</span>
|
||
<span>{{ Math.round((sidebarDangerDialogRequest.progress.completed / sidebarDangerDialogRequest.progress.total) * 100) }}%</span>
|
||
</div>
|
||
<div class="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="sidebarDangerDialogRequest.progress.total" :aria-valuenow="sidebarDangerDialogRequest.progress.completed">
|
||
<div class="h-full bg-primary transition-[width] duration-200" :style="{ width: `${Math.round((sidebarDangerDialogRequest.progress.completed / sidebarDangerDialogRequest.progress.total) * 100)}%` }" />
|
||
</div>
|
||
</div>
|
||
<label v-if="sidebarDangerDialogRequest.option" 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" @changed="refreshSidebarActionTarget" />
|
||
<ExtensionDetailsDialog v-if="sidebarExtensionDetailsTarget" ref="sidebarExtensionDetailsDialogRef" :node="sidebarExtensionDetailsTarget" />
|
||
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||
{{ t("sidebar.noConnections") }}
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sticky-database-header {
|
||
background-color: var(--background);
|
||
}
|
||
|
||
.connection-tree-scroller {
|
||
will-change: scroll-position;
|
||
contain: content;
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
}
|
||
|
||
.connection-tree-scroller::-webkit-scrollbar {
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
|
||
.connection-tree-scroller :deep(.vue-recycle-scroller__item-view) {
|
||
min-width: 100%;
|
||
contain: style;
|
||
}
|
||
|
||
.connection-tree-scroller.sidebar-tree-horizontal-scroll :deep(.vue-recycle-scroller__item-view) {
|
||
width: max-content;
|
||
}
|
||
|
||
.connection-tree-scroller.sidebar-tree-horizontal-scroll :deep(.connection-tree-content),
|
||
.connection-tree-scroller.sidebar-tree-horizontal-scroll > .connection-tree-content {
|
||
min-width: max(100%, var(--sidebar-tree-content-width));
|
||
}
|
||
|
||
.connection-tree-scroll-shell--horizontal-overflow .connection-tree-scroller {
|
||
height: calc(100% - 10px);
|
||
}
|
||
|
||
.sidebar-tree-scrollbar {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 10;
|
||
width: 12px;
|
||
cursor: default;
|
||
opacity: 0;
|
||
transition: opacity 120ms ease;
|
||
}
|
||
|
||
.sidebar-tree-scrollbar--with-horizontal {
|
||
bottom: 10px;
|
||
}
|
||
|
||
.sidebar-tree-scrollbar--scrolling,
|
||
.sidebar-tree-scrollbar:hover,
|
||
.sidebar-tree-scrollbar--dragging {
|
||
opacity: 1;
|
||
}
|
||
|
||
.sidebar-tree-scrollbar__thumb {
|
||
position: absolute;
|
||
right: 2px;
|
||
width: 6px;
|
||
min-height: 24px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklch, var(--foreground) 30%, transparent);
|
||
transition:
|
||
background-color 120ms ease,
|
||
width 120ms ease,
|
||
right 120ms ease;
|
||
}
|
||
|
||
.sidebar-tree-scrollbar:hover .sidebar-tree-scrollbar__thumb,
|
||
.sidebar-tree-scrollbar--dragging .sidebar-tree-scrollbar__thumb {
|
||
right: 1px;
|
||
width: 8px;
|
||
background: color-mix(in oklch, var(--foreground) 48%, transparent);
|
||
}
|
||
|
||
.sidebar-tree-horizontal-scrollbar {
|
||
position: absolute;
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
z-index: 10;
|
||
height: 10px;
|
||
cursor: default;
|
||
background: color-mix(in oklch, var(--muted) 45%, transparent);
|
||
}
|
||
|
||
.sidebar-tree-horizontal-scrollbar--with-vertical {
|
||
right: 10px;
|
||
}
|
||
|
||
.sidebar-tree-horizontal-scrollbar__thumb {
|
||
position: absolute;
|
||
bottom: 2px;
|
||
left: 0;
|
||
height: 6px;
|
||
min-width: 24px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklch, var(--foreground) 34%, transparent);
|
||
transition:
|
||
background-color 120ms ease,
|
||
height 120ms ease,
|
||
bottom 120ms ease;
|
||
}
|
||
|
||
.sidebar-tree-horizontal-scrollbar:hover .sidebar-tree-horizontal-scrollbar__thumb,
|
||
.sidebar-tree-horizontal-scrollbar--dragging .sidebar-tree-horizontal-scrollbar__thumb {
|
||
bottom: 1px;
|
||
height: 8px;
|
||
background: color-mix(in oklch, var(--foreground) 50%, transparent);
|
||
}
|
||
</style>
|