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") }}
-
+
+
{{ formattedObjectStorage() }}