diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 20c255eb6..1cbf77513 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -53,7 +53,7 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue"; import * as api from "@/lib/backend/api"; -import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TriggerInfo } from "@/types/database"; +import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database"; import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort"; import { isSchemaAware } from "@/lib/database/databaseCapabilities"; import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport"; @@ -72,6 +72,7 @@ import { formatSqlInsert } from "@/lib/export/exportFormats"; import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport"; import { fetchTableDataForExport } from "@/lib/table/tableDataExport"; import { useConnectionStore } from "@/stores/connectionStore"; +import { treeNodePinIdentity, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems"; import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { useSettingsStore } from "@/stores/settingsStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -88,7 +89,11 @@ import { formatObjectBrowserBytes, formatObjectBrowserCount, formatObjectBrowserTimestamp, + canonicalizeObjectBrowserPinnedTreeNodeIdentity, initialObjectBrowserSortDirection, + objectBrowserRowLegacyPinnedTreeNodeIds, + objectBrowserRowMatchesPinnedTreeNode, + objectBrowserRowPinnedTreeNodeIdentity, sortObjectBrowserRows, summarizeObjectBrowserSearch, type ObjectBrowserFilter, @@ -656,6 +661,55 @@ function rowMatchesObjectFilter(row: ObjectBrowserRow) { return rowMatchesFilter(row, objectFilter.value); } +function objectBrowserPinnedTreeNodeContext() { + return { + connectionId: props.connection.id, + database: props.database, + schema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value), + catalog: props.catalog, + sidebarParentId: props.catalog + ? `${props.connection.id}:doris-catalog:${encodeURIComponent(props.catalog)}:${encodeURIComponent(props.database)}` + : needsSchema.value && selectedSchema.value + ? `${props.connection.id}:${props.database}:${selectedSchema.value}` + : `${props.connection.id}:${props.database}`, + }; +} + +function canonicalizeObjectBrowserPinnedIdentity(identity: PinnedTreeNodeIdentity): PinnedTreeNodeIdentity { + return canonicalizeObjectBrowserPinnedTreeNodeIdentity(objectBrowserPinnedTreeNodeContext())(identity); +} + +function sortObjectBrowserRowsWithPins(items: ObjectBrowserRow[]): ObjectBrowserRow[] { + const sorted = sortObjectBrowserRows(items, sortKey.value, sortDirection.value); + const context = objectBrowserPinnedTreeNodeContext(); + return connectionStore.orderByPinnedTreeNodes(sorted, (row, identity) => objectBrowserRowMatchesPinnedTreeNode(row, identity, context)); +} + +function pinnedTreeNodeForObjectBrowserRow(row: ObjectBrowserRow): TreeNode { + const identity = objectBrowserRowPinnedTreeNodeIdentity(row, objectBrowserPinnedTreeNodeContext()); + return { + id: identity.id, + label: identity.name, + type: identity.type, + objectName: identity.name, + signature: identity.signature || undefined, + connectionId: identity.connectionId, + database: identity.database, + schema: identity.schema || undefined, + catalog: identity.catalog || undefined, + }; +} + +function legacyPinnedTreeNodesForObjectBrowserRow(row: ObjectBrowserRow): TreeNode[] { + const baseNode = pinnedTreeNodeForObjectBrowserRow(row); + return objectBrowserRowLegacyPinnedTreeNodeIds(row, objectBrowserPinnedTreeNodeContext()).map((id) => ({ ...baseNode, id })); +} + +function removePinnedObjectBrowserRows(rows: readonly ObjectBrowserRow[]) { + const nodes = rows.flatMap((row) => [pinnedTreeNodeForObjectBrowserRow(row), ...legacyPinnedTreeNodesForObjectBrowserRow(row)]); + connectionStore.removePinnedTreeNodes(nodes, canonicalizeObjectBrowserPinnedIdentity); +} + function groupedFilteredRows() { const query = search.value.trim(); const candidateRows = rows.value.filter(rowMatchesObjectFilter); @@ -668,7 +722,7 @@ function groupedFilteredRows() { if (!query) return true; return matchingIds.has(row.id) || parentIdsWithMatchingPartitions.has(row.id); }); - const sortedRoots = sortObjectBrowserRows(rootRows, sortKey.value, sortDirection.value); + const sortedRoots = sortObjectBrowserRowsWithPins(rootRows); const result: ObjectBrowserRow[] = []; for (const row of sortedRoots) { @@ -679,7 +733,7 @@ function groupedFilteredRows() { const shouldShowPartitions = expandedPartitionParentIds.value.has(row.id) || !!query; if (!shouldShowPartitions) continue; const visiblePartitions = query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id)) : partitions; - result.push(...sortObjectBrowserRows(visiblePartitions, sortKey.value, sortDirection.value)); + result.push(...sortObjectBrowserRowsWithPins(visiblePartitions)); } return result; @@ -1151,6 +1205,9 @@ async function confirmRename() { const newName = renameInput.value.trim(); if (!row || !newName || newName === row.name) return; renameError.value = ""; + const oldPinnedNode = pinnedTreeNodeForObjectBrowserRow(row); + const oldLegacyPinnedNodes = legacyPinnedTreeNodesForObjectBrowserRow(row); + let renameApplied = false; try { const schema = row.schema || selectedSchema.value || props.database; if (supportsSourceBackedRoutineRename(effectiveDatabaseType.value, row.type as ObjectSourceKind)) { @@ -1181,12 +1238,32 @@ async function confirmRename() { const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema)); if (!executed) return; } + renameApplied = true; toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName })); showRenameDialog.value = false; if (sourceRow.value?.id === row.id) closeSource(); + const renamedTarget = { ...oldPinnedNode, label: newName, objectName: newName, tableName: newName }; await reload(); await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value); + const renamedRow = rows.value.find((candidate) => objectBrowserRowMatchesPinnedTreeNode(candidate, treeNodePinIdentity(renamedTarget), objectBrowserPinnedTreeNodeContext())); + if (renamedRow) { + connectionStore.replacePinnedTreeNode( + oldPinnedNode, + pinnedTreeNodeForObjectBrowserRow(renamedRow), + canonicalizeObjectBrowserPinnedIdentity, + oldLegacyPinnedNodes.map((node) => node.id), + ); + } else { + // The database mutation succeeded, so never leave the old name pinned if + // metadata refresh cannot resolve its replacement. + connectionStore.removePinnedTreeNodes([oldPinnedNode, ...oldLegacyPinnedNodes], canonicalizeObjectBrowserPinnedIdentity); + } } catch (e: any) { + if (renameApplied) { + // The database mutation succeeded even when metadata refresh did not; + // remove the old pin instead of allowing it to revive later. + connectionStore.removePinnedTreeNodes([oldPinnedNode, ...oldLegacyPinnedNodes], canonicalizeObjectBrowserPinnedIdentity); + } renameError.value = e?.message || String(e); } } @@ -1201,6 +1278,7 @@ async function confirmDrop() { const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess"; toast(t(successKey, { name: row.name })); closeDroppedTableObjectTabsForRow(row); + removePinnedObjectBrowserRows([row]); await reload(); await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value); } catch (e: any) { @@ -1448,6 +1526,7 @@ async function confirmBatchDropTables() { }); if (!executed) return; toast(t("objects.batchDropSuccess", { count: targets.length })); + removePinnedObjectBrowserRows(targets); clearTableSelection(); await reload(); await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index b029134e8..dc62deb8e 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1844,6 +1844,7 @@ async function confirmRenameObject() { const newName = renameObjectName.value.trim(); if (!objectType || !newName || newName === node.label || !node.connectionId || !node.database) return; renameObjectError.value = ""; + let renameApplied = false; try { const dbType = databaseTypeForNode(node); await connectionStore.ensureConnected(node.connectionId); @@ -1871,10 +1872,18 @@ async function confirmRenameObject() { }); await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema }); } + renameApplied = true; toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000); showRenameObjectDialog.value = false; + const renamedNode: TreeNode = { ...node, label: newName, objectName: newName, tableName: newName }; await refreshTableList(node); + connectionStore.replacePinnedTreeNode(node, renamedNode); } catch (e: any) { + if (renameApplied) { + // The database mutation succeeded even when metadata refresh did not; + // remove the old pin instead of allowing it to revive later. + connectionStore.removePinnedTreeNodes([node]); + } renameObjectError.value = e?.message || String(e); } } @@ -1891,6 +1900,9 @@ async function confirmDropObject() { const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess"; toast(t(msgKey, { name: node.label }), 3000); closeDroppedTableObjectTabsForNode(node); + // Procedure/function drops refresh their parent instead of removing this + // node directly, so clear their pin before the old identity can survive. + connectionStore.removePinnedTreeNodes([node]); if (node.type === "view" || node.type === "materialized_view") { connectionStore.removeTreeNode(node.id); releaseActiveNodeReference([node.id]); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index bffe748f2..5f6c5dac1 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -62,6 +62,7 @@ import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus"; // --- Drag and Drop --- import { useDragSort } from "@/composables/useDragSort"; import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime"; +import { treeNodePinKey } from "@/lib/app/pinnedItems"; const { t } = useI18n(); @@ -559,7 +560,11 @@ const canExpand = computed(() => const isPinned = computed(() => activeNode.value.pinned || connectionStore.isTreeNodePinned(activeNode.value)); const isNodeDefaultDatabase = computed( - () => (activeNode.value.type === "database" || activeNode.value.type === "redis-db" || activeNode.value.type === "mongo-db") && !!activeNode.value.connectionId && !!activeNode.value.database && connectionStore.isDefaultDatabase(activeNode.value.connectionId, activeNode.value.database), + () => + (activeNode.value.type === "database" || activeNode.value.type === "redis-db" || activeNode.value.type === "mongo-db") && + !!activeNode.value.connectionId && + typeof activeNode.value.database === "string" && + connectionStore.isDefaultDatabase(activeNode.value.connectionId, activeNode.value.database), ); const trailingComment = computed(() => { @@ -768,12 +773,27 @@ function finishRenameGroup() { connectionStore.renameConnectionGroup(activeNode.value.id, trimmed); } +const PINNED_TREE_NODE_DRAG_TYPE = "__pinned-tree-node__"; + +function pinnedSortKey(): string { + return treeNodePinKey(activeNode.value); +} + +function canDragPinnedOrder(): boolean { + return isPinned.value && !isNodeDefaultDatabase.value && !props.dragDisabled; +} + const { state: dragState, startDrag, updateTarget, clearTarget, } = useDragSort((draggedId, targetId, position) => { + if (dragState.draggedType === PINNED_TREE_NODE_DRAG_TYPE) { + connectionStore.reorderPinnedTreeNodes(draggedId, targetId, position); + return; + } + // If the grabbed row is part of a multi-selection, move all selected rows // together; otherwise just the grabbed one (issue #681). const selected = connectionStore.selectedTreeNodeIds; @@ -786,13 +806,40 @@ const isDraggable = computed(() => { return activeNode.value.type === "connection" || activeNode.value.type === "connection-group"; }); -const dragVisual = computed(() => ({ - isDropTarget: activeNode.value.type === "connection" || activeNode.value.type === "connection-group", - showBefore: dragState.active && dragState.targetId === activeNode.value.id && dragState.dropPosition === "before", - showAfter: dragState.active && dragState.targetId === activeNode.value.id && dragState.dropPosition === "after", - showInside: dragState.active && dragState.targetId === activeNode.value.id && dragState.dropPosition === "inside", - dragging: dragState.active && dragState.draggedId === activeNode.value.id, -})); +function isPinnedOrderDrag(): boolean { + return dragState.active && dragState.draggedType === PINNED_TREE_NODE_DRAG_TYPE; +} + +const dragVisual = computed(() => { + const targetId = isPinnedOrderDrag() ? pinnedSortKey() : activeNode.value.id; + const isDropTarget = isPinnedOrderDrag() ? !!dragState.draggedId && connectionStore.canReorderPinnedTreeNodes(dragState.draggedId, pinnedSortKey()) : activeNode.value.type === "connection" || activeNode.value.type === "connection-group"; + + return { + isDropTarget, + showBefore: dragState.active && dragState.targetId === targetId && dragState.dropPosition === "before", + showAfter: dragState.active && dragState.targetId === targetId && dragState.dropPosition === "after", + showInside: !isPinnedOrderDrag() && dragState.active && dragState.targetId === targetId && dragState.dropPosition === "inside", + dragging: dragState.active && dragState.draggedId === targetId, + }; +}); + +function startPinnedOrderDrag(event: MouseEvent) { + if (!canDragPinnedOrder()) return; + startDrag(event, pinnedSortKey(), PINNED_TREE_NODE_DRAG_TYPE); +} + +function updateTreeDragTarget(event: MouseEvent) { + if (!dragState.active || !dragVisual.value.isDropTarget) return; + if (isPinnedOrderDrag()) { + updateTarget(event, pinnedSortKey(), PINNED_TREE_NODE_DRAG_TYPE); + return; + } + updateTarget(event, activeNode.value.id, activeNode.value.type); +} + +function clearTreeDragTarget() { + clearTarget(isPinnedOrderDrag() ? pinnedSortKey() : activeNode.value.id); +} const TABLE_REFERENCE_DRAG_THRESHOLD = 5; @@ -1060,10 +1107,10 @@ function onKeydown(event: KeyboardEvent) { @dblclick="onDoubleClick" @keydown="onKeydown" @mousedown="onRowMouseDown" - @mousemove="dragVisual.isDropTarget ? updateTarget($event, node.id, node.type) : undefined" + @mousemove="updateTreeDragTarget" @mouseenter="handleMouseEnter" @mouseleave=" - clearTarget(node.id); + clearTreeDragTarget(); handleMouseLeave(); " > @@ -1124,7 +1171,19 @@ function onKeydown(event: KeyboardEvent) { {{ t("connection.readOnlyBadge") }} -