feat(sidebar): persist custom pinned item ordering
This commit is contained in:
parent
b635ea1466
commit
406b7acdbb
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<span v-if="databaseOpenVisual.showsIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"><Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }}</Badge>
|
||||
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
|
||||
<Pin v-if="isPinned" class="w-3 h-3 shrink-0 text-primary fill-current" aria-hidden="true" />
|
||||
<button
|
||||
v-if="canDragPinnedOrder()"
|
||||
type="button"
|
||||
class="flex h-4 w-4 shrink-0 cursor-grab items-center justify-center rounded-sm text-primary hover:bg-primary/10 active:cursor-grabbing"
|
||||
:aria-label="t('contextMenu.reorderPinned')"
|
||||
:title="t('contextMenu.reorderPinned')"
|
||||
@mousedown.stop="startPinnedOrderDrag"
|
||||
@click.stop.prevent
|
||||
@dblclick.stop.prevent
|
||||
>
|
||||
<Pin class="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
</button>
|
||||
<Pin v-else-if="isPinned" class="w-3 h-3 shrink-0 text-primary fill-current" aria-hidden="true" />
|
||||
<span v-if="formattedObjectStorage()" class="ml-auto shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formattedObjectStorage() }}</span>
|
||||
<button
|
||||
v-if="isConnecting"
|
||||
|
|
|
|||
|
|
@ -1803,6 +1803,7 @@ export default {
|
|||
tableNameFilterExcludePlaceholder: "Example:\n%_bak\ntmp_%",
|
||||
tableNameFilterLikeHint: "Use SQL LIKE syntax: % matches any characters, _ matches one character. Matching is case-insensitive.",
|
||||
pin: "Pin",
|
||||
reorderPinned: "Reorder pinned item",
|
||||
unpin: "Unpin",
|
||||
fixTab: "Fix Tab",
|
||||
unfixTab: "Unfix Tab",
|
||||
|
|
|
|||
|
|
@ -1742,6 +1742,7 @@ export default withEnglishFallback({
|
|||
tableNameFilterExcludePlaceholder: "Ejemplo:\n%_bak\ntmp_%",
|
||||
tableNameFilterLikeHint: "Usa sintaxis SQL LIKE: % coincide con cualquier texto, _ con un carácter. No distingue mayúsculas.",
|
||||
pin: "Fijar",
|
||||
reorderPinned: "Reordenar elemento fijado",
|
||||
unpin: "Desfijar",
|
||||
fixTab: "Fijar pestaña",
|
||||
unfixTab: "Desfijar pestaña",
|
||||
|
|
|
|||
|
|
@ -1740,6 +1740,7 @@ export default withEnglishFallback({
|
|||
tableNameFilterExcludePlaceholder: "Esempio:\n%_bak\ntmp_%",
|
||||
tableNameFilterLikeHint: "Usa la sintassi SQL LIKE: % corrisponde a qualsiasi testo, _ a un carattere. Match case-insensitive.",
|
||||
pin: "Fissa",
|
||||
reorderPinned: "Riordina elemento fissato",
|
||||
unpin: "Sblocca",
|
||||
fixTab: "Blocca scheda",
|
||||
unfixTab: "Sblocca scheda",
|
||||
|
|
|
|||
|
|
@ -1739,6 +1739,7 @@ export default withEnglishFallback({
|
|||
tableNameFilterExcludePlaceholder: "例:\n%_bak\ntmp_%",
|
||||
tableNameFilterLikeHint: "SQL LIKE 構文を使用します。% は任意の文字列、_ は 1 文字に一致します。大文字小文字は区別しません。",
|
||||
pin: "ピン留め",
|
||||
reorderPinned: "ピン留め項目を並べ替え",
|
||||
unpin: "ピン留め解除",
|
||||
fixTab: "タブを固定",
|
||||
unfixTab: "タブの固定を解除",
|
||||
|
|
|
|||
|
|
@ -1742,6 +1742,7 @@ export default withEnglishFallback({
|
|||
tableNameFilterExcludePlaceholder: "Exemplo:\n%_bak\ntmp_%",
|
||||
tableNameFilterLikeHint: "Use sintaxe SQL LIKE: % corresponde a qualquer texto, _ a um caractere. A comparação ignora maiúsculas.",
|
||||
pin: "Fixar",
|
||||
reorderPinned: "Reordenar item fixado",
|
||||
unpin: "Desafixar",
|
||||
fixTab: "Fixar aba",
|
||||
unfixTab: "Desafixar aba",
|
||||
|
|
|
|||
|
|
@ -1802,6 +1802,7 @@ export default withEnglishFallback({
|
|||
tableNameFilterExcludePlaceholder: "例如:\n%_bak\ntmp_%",
|
||||
tableNameFilterLikeHint: "使用 SQL LIKE 语法:% 匹配任意字符,_ 匹配单个字符。匹配时不区分大小写。",
|
||||
pin: "置顶",
|
||||
reorderPinned: "拖动调整置顶顺序",
|
||||
unpin: "取消置顶",
|
||||
fixTab: "固定标签页",
|
||||
unfixTab: "取消固定标签页",
|
||||
|
|
|
|||
|
|
@ -1741,6 +1741,7 @@ export default withEnglishFallback({
|
|||
tableNameFilterExcludePlaceholder: "範例:\n%_bak\ntmp_%",
|
||||
tableNameFilterLikeHint: "使用 SQL LIKE 語法:% 符合任意字元,_ 符合單一字元。比對不區分大小寫。",
|
||||
pin: "置頂",
|
||||
reorderPinned: "拖曳調整置頂順序",
|
||||
unpin: "取消置頂",
|
||||
fixTab: "固定分頁",
|
||||
unfixTab: "取消固定分頁",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { reactive } from "vue";
|
||||
import { inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems";
|
||||
import {
|
||||
inheritNaturalTreeNodeOrder,
|
||||
migrateLegacyPinnedTreeNodeIds,
|
||||
migrateLegacyPinnedTreeNodeOrder,
|
||||
removePinnedTreeNodesFromOrder,
|
||||
reorderPinnedTreeNodeOrder,
|
||||
replacePinnedTreeNodeInOrder,
|
||||
syncPinnedTreeNodeStateInPlace,
|
||||
treeNodePinKey,
|
||||
updatePinnedTreeNodeInPlace,
|
||||
} from "@/lib/app/pinnedItems";
|
||||
import { buildTreeNodesFromLayout } from "@/lib/sidebar/sidebarLayout";
|
||||
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
|
||||
|
||||
|
|
@ -146,6 +156,31 @@ describe("sidebar pinned tree nodes", () => {
|
|||
expect(tableB.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("removes a deleted table pin so a recreated or renamed table does not inherit it", () => {
|
||||
const deletedTable: TreeNode = { id: "conn:db:public:users", label: "users", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "users" };
|
||||
const anotherTable: TreeNode = { id: "conn:db:public:orders", label: "orders", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "orders" };
|
||||
const pinOrder = [treeNodePinKey(deletedTable), treeNodePinKey(anotherTable)];
|
||||
|
||||
const remainingOrder = removePinnedTreeNodesFromOrder(pinOrder, [{ ...deletedTable, id: "object-browser-row-id" }]);
|
||||
const recreatedTable: TreeNode = { ...deletedTable };
|
||||
const renamedTable: TreeNode = { ...anotherTable, id: deletedTable.id, label: deletedTable.label, tableName: deletedTable.tableName };
|
||||
|
||||
expect(remainingOrder).toEqual([treeNodePinKey(anotherTable)]);
|
||||
expect(remainingOrder).not.toContain(treeNodePinKey(recreatedTable));
|
||||
expect(remainingOrder).not.toContain(treeNodePinKey(renamedTable));
|
||||
});
|
||||
|
||||
it("moves a renamed pin to the new identity without retaining the old name", () => {
|
||||
const users: TreeNode = { id: "conn:db:public:users", label: "users", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "users" };
|
||||
const orders: TreeNode = { id: "conn:db:public:orders", label: "orders", type: "table", connectionId: "conn", database: "db", schema: "public", tableName: "orders" };
|
||||
const accounts: TreeNode = { ...users, id: "conn:db:public:accounts", label: "accounts", tableName: "accounts" };
|
||||
|
||||
const renamedOrder = replacePinnedTreeNodeInOrder([treeNodePinKey(users), treeNodePinKey(orders)], users, accounts);
|
||||
|
||||
expect(renamedOrder).toEqual([treeNodePinKey(accounts), treeNodePinKey(orders)]);
|
||||
expect(renamedOrder).not.toContain(treeNodePinKey(users));
|
||||
});
|
||||
|
||||
it("migrates a legacy id once instead of pinning every colliding node", () => {
|
||||
const tableA: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "a" };
|
||||
const tableB: TreeNode = { id: "duplicate-table-id", label: "users", type: "table", connectionId: "conn", database: "b" };
|
||||
|
|
@ -156,6 +191,91 @@ describe("sidebar pinned tree nodes", () => {
|
|||
expect(migrated.ids).toEqual(new Set([treeNodePinKey(tableA)]));
|
||||
});
|
||||
|
||||
it("uses the persisted order for pinned siblings", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const nodeB: TreeNode = { id: "db-b", label: "B", type: "database", connectionId: "conn", database: "b" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, nodeB, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-c", "db-a", "db-b"]);
|
||||
});
|
||||
|
||||
it("keeps the fixed default database before manually pinned siblings", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const defaultNode: TreeNode = { id: "db-default", label: "Default", type: "database", connectionId: "conn", database: "default" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, defaultNode, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order, (node) => node.id === defaultNode.id);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-default", "db-c", "db-a"]);
|
||||
expect(defaultNode.pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the fixed default database first even when it is also manually pinned", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const defaultNode: TreeNode = { id: "db-default", label: "Default", type: "database", connectionId: "conn", database: "default" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, defaultNode, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA), treeNodePinKey(defaultNode)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order, (node) => node.id === defaultNode.id);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-default", "db-c", "db-a"]);
|
||||
expect(defaultNode.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it("reorders pinned keys before and after a sibling without dropping unrelated keys", () => {
|
||||
const initial = ["scope:a", "other:x", "scope:b", "other:y", "scope:c"];
|
||||
|
||||
const before = reorderPinnedTreeNodeOrder(initial, "scope:c", "scope:a", "before");
|
||||
expect(before).toEqual(["scope:c", "scope:a", "other:x", "scope:b", "other:y"]);
|
||||
|
||||
const after = reorderPinnedTreeNodeOrder(before, "scope:c", "scope:b", "after");
|
||||
expect(after).toEqual(["scope:a", "other:x", "scope:b", "scope:c", "other:y"]);
|
||||
expect(after.filter((key) => key.startsWith("other:"))).toEqual(["other:x", "other:y"]);
|
||||
});
|
||||
|
||||
it("places a newly appended pin last within its sibling pin section", () => {
|
||||
const nodeA: TreeNode = { id: "db-a", label: "A", type: "database", connectionId: "conn", database: "a" };
|
||||
const nodeB: TreeNode = { id: "db-b", label: "B", type: "database", connectionId: "conn", database: "b" };
|
||||
const nodeC: TreeNode = { id: "db-c", label: "C", type: "database", connectionId: "conn", database: "c" };
|
||||
const nodes = [nodeA, nodeB, nodeC];
|
||||
const order = [treeNodePinKey(nodeC), treeNodePinKey(nodeA), treeNodePinKey(nodeB)];
|
||||
|
||||
syncPinnedTreeNodeStateInPlace(nodes, new Set(order), order);
|
||||
|
||||
expect(nodes.map((node) => node.id)).toEqual(["db-c", "db-a", "db-b"]);
|
||||
});
|
||||
|
||||
it("migrates a legacy pin key in place without changing persisted order", () => {
|
||||
const node: TreeNode = { id: "legacy-table", label: "users", type: "table", connectionId: "conn", database: "db" };
|
||||
|
||||
const migrated = migrateLegacyPinnedTreeNodeOrder([node], ["before", node.id, "after"]);
|
||||
|
||||
expect(migrated.changed).toBe(true);
|
||||
expect(migrated.order).toEqual(["before", treeNodePinKey(node), "after"]);
|
||||
});
|
||||
|
||||
it("removes explicitly supplied legacy keys for an unloaded node", () => {
|
||||
const node: TreeNode = { id: "object-browser:app:events", label: "events", type: "table", connectionId: "conn", database: "app", schema: "public" };
|
||||
const legacyKey = "conn:app:public:__tables:public:events";
|
||||
|
||||
expect(removePinnedTreeNodesFromOrder([legacyKey, "unrelated"], [node], undefined, [legacyKey])).toEqual(["unrelated"]);
|
||||
});
|
||||
|
||||
it("replaces an explicitly supplied legacy key without losing its position", () => {
|
||||
const oldNode: TreeNode = { id: "object-browser:app:events", label: "events", type: "table", connectionId: "conn", database: "app", schema: "public" };
|
||||
const newNode: TreeNode = { id: "conn:app:public:__tables:public:renamed", label: "renamed", type: "table", connectionId: "conn", database: "app", schema: "public" };
|
||||
const legacyKey = "conn:app:public:__tables:public:events";
|
||||
|
||||
expect(replacePinnedTreeNodeInOrder(["before", legacyKey, "after"], oldNode, newNode, undefined, [legacyKey])).toEqual(["before", treeNodePinKey(newNode), "after"]);
|
||||
});
|
||||
|
||||
it("applies pinned state to connection groups when rebuilding from layout", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildObjectBrowserRows } from "@/lib/table/objectBrowserRows";
|
||||
import { orderItemsByPinnedTreeNodeOrder, removePinnedTreeNodesFromOrder, treeNodePinIdentity, treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import { buildObjectBrowserRows, canonicalizeObjectBrowserPinnedTreeNodeIdentity, objectBrowserRowLegacyPinnedTreeNodeIds, objectBrowserRowMatchesPinnedTreeNode, sortObjectBrowserRows, type ObjectBrowserRow } from "@/lib/table/objectBrowserRows";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
describe("buildObjectBrowserRows", () => {
|
||||
it("preserves a resolved SQLite attached-database alias on every row", () => {
|
||||
|
|
@ -24,3 +26,96 @@ describe("buildObjectBrowserRows", () => {
|
|||
expect(rows[0]?.schema).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Object Browser pinned ordering", () => {
|
||||
const context = { connectionId: "conn", database: "app", schema: "public" };
|
||||
|
||||
function tableNode(name: string, schema = "public"): TreeNode {
|
||||
return {
|
||||
id: `conn:app:${schema}:tables:${name}`,
|
||||
label: name,
|
||||
type: "table",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
schema,
|
||||
};
|
||||
}
|
||||
|
||||
function orderRows(rows: ObjectBrowserRow[], pinnedNodes: TreeNode[], extraPinnedKeys: string[] = []): ObjectBrowserRow[] {
|
||||
const pinnedOrder = [...extraPinnedKeys, ...pinnedNodes.map(treeNodePinKey)];
|
||||
return orderItemsByPinnedTreeNodeOrder(rows, pinnedOrder, (row, identity) => objectBrowserRowMatchesPinnedTreeNode(row, identity, context));
|
||||
}
|
||||
|
||||
it("uses the sidebar's persisted custom pin order before the selected name sort", () => {
|
||||
const rows = buildObjectBrowserRows({
|
||||
objects: ["a1", "a2", "a3", "a4", "a5", "aaa"].map((name) => ({ name, object_type: "TABLE", schema: "public" })),
|
||||
database: "app",
|
||||
fallbackSchema: "public",
|
||||
rowSchema: "public",
|
||||
});
|
||||
const sorted = sortObjectBrowserRows(rows, "name", "asc");
|
||||
const pinnedNodes = ["aaa", "a5", "a4", "a3", "a2", "a1"].map((name) => tableNode(name));
|
||||
|
||||
const ordered = orderRows(sorted, pinnedNodes, [treeNodePinKey({ ...tableNode("other"), connectionId: "other-connection" })]);
|
||||
|
||||
expect(ordered.map((row) => row.name)).toEqual(["aaa", "a5", "a4", "a3", "a2", "a1"]);
|
||||
});
|
||||
|
||||
it("keeps unpinned rows in the Object Browser's selected column order", () => {
|
||||
const rows: ObjectBrowserRow[] = [
|
||||
{ id: "a1", name: "a1", displayName: "a1", schema: "public", type: "TABLE", totalBytes: 30 },
|
||||
{ id: "a2", name: "a2", displayName: "a2", schema: "public", type: "TABLE", totalBytes: 40 },
|
||||
{ id: "a3", name: "a3", displayName: "a3", schema: "public", type: "TABLE", totalBytes: 10 },
|
||||
{ id: "a4", name: "a4", displayName: "a4", schema: "public", type: "TABLE", totalBytes: 20 },
|
||||
];
|
||||
const sorted = sortObjectBrowserRows(rows, "totalBytes", "desc");
|
||||
|
||||
const ordered = orderRows(sorted, [tableNode("a3"), tableNode("a1")]);
|
||||
|
||||
expect(ordered.map((row) => row.name)).toEqual(["a3", "a1", "a2", "a4"]);
|
||||
});
|
||||
|
||||
it("matches a sidebar database child when Object Browser reports the database name as its schema", () => {
|
||||
const row: ObjectBrowserRow = { id: "a1", name: "a1", displayName: "a1", schema: "app", type: "TABLE" };
|
||||
const sidebarNode = tableNode("a1", "");
|
||||
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(row, treeNodePinIdentity(sidebarNode), { connectionId: "conn", database: "app", schema: "app" })).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the database-as-schema alias when an Object Browser object is deleted", () => {
|
||||
const sidebarNode = tableNode("events", "");
|
||||
const objectBrowserNode: TreeNode = { ...sidebarNode, id: "object-browser:app:events", schema: "app" };
|
||||
const canonicalize = canonicalizeObjectBrowserPinnedTreeNodeIdentity({ connectionId: "conn", database: "app" });
|
||||
|
||||
const remainingOrder = removePinnedTreeNodesFromOrder([treeNodePinKey(sidebarNode)], [objectBrowserNode], canonicalize);
|
||||
|
||||
expect(remainingOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs legacy sidebar IDs for an unloaded Object Browser row", () => {
|
||||
const row: ObjectBrowserRow = { id: "object-browser:events:0", name: "events", displayName: "events", schema: "public", type: "TABLE" };
|
||||
const ids = objectBrowserRowLegacyPinnedTreeNodeIds(row, { connectionId: "conn", database: "app", schema: "public", sidebarParentId: "conn:app:public" });
|
||||
|
||||
expect(ids).toEqual(expect.arrayContaining(["conn:app:public:events", "conn:app:public:__tables:public:events"]));
|
||||
});
|
||||
|
||||
it("does not confuse same-name objects across schemas or routine overloads", () => {
|
||||
const tableRow: ObjectBrowserRow = { id: "orders", name: "orders", displayName: "orders", schema: "public", type: "TABLE" };
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(tableRow, treeNodePinIdentity(tableNode("orders", "archive")), context)).toBe(false);
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(tableRow, treeNodePinIdentity(tableNode("orders", "public")), context)).toBe(true);
|
||||
|
||||
const routineRow: ObjectBrowserRow = { id: "run-int", name: "run", displayName: "run(integer)", schema: "public", type: "FUNCTION", signature: "integer" };
|
||||
const routineNode: TreeNode = {
|
||||
id: "conn:app:public:functions:run:text",
|
||||
label: "run(text)",
|
||||
objectName: "run",
|
||||
signature: "text",
|
||||
type: "function",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
schema: "public",
|
||||
};
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity(routineNode), context)).toBe(false);
|
||||
expect(objectBrowserRowMatchesPinnedTreeNode(routineRow, treeNodePinIdentity({ ...routineNode, id: "conn:app:public:functions:run:integer", label: "run(integer)", signature: "integer" }), context)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
export type PinnedTreeNodeUpdateScope = "missing" | "root" | "siblings";
|
||||
export type PinnedTreeNodeDropPosition = "before" | "after";
|
||||
export type FixedTreeNodePriority = (node: TreeNode) => boolean;
|
||||
export type PinnedTreeNodeIdentityCanonicalizer = (identity: PinnedTreeNodeIdentity) => PinnedTreeNodeIdentity;
|
||||
export type PinnedTreeNodeIdentity = {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema: string;
|
||||
catalog: string;
|
||||
type: TreeNode["type"];
|
||||
name: string;
|
||||
signature: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const NATURAL_TREE_NODE_ORDER = Symbol("naturalTreeNodeOrder");
|
||||
type OrderedTreeNode = TreeNode & { [NATURAL_TREE_NODE_ORDER]?: number };
|
||||
|
|
@ -21,23 +34,129 @@ export function inheritNaturalTreeNodeOrder(source: TreeNode, target: TreeNode):
|
|||
return target;
|
||||
}
|
||||
|
||||
export function treeNodePinKey(node: TreeNode): string {
|
||||
if (!node.connectionId) return node.id;
|
||||
const identity = [node.database || "", node.schema || "", node.catalog || "", node.type, node.objectName || node.tableName || node.label, node.signature || "", node.id];
|
||||
return `${node.connectionId}:pin:v2:${encodeURIComponent(JSON.stringify(identity))}`;
|
||||
export function treeNodePinIdentity(node: TreeNode): PinnedTreeNodeIdentity {
|
||||
return {
|
||||
connectionId: node.connectionId || "",
|
||||
database: node.database || "",
|
||||
schema: node.schema || "",
|
||||
catalog: node.catalog || "",
|
||||
type: node.type,
|
||||
name: node.objectName || node.tableName || node.label,
|
||||
signature: node.signature || "",
|
||||
id: node.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinnedIds: Set<string>): { ids: Set<string>; changed: boolean } {
|
||||
const next = new Set(pinnedIds);
|
||||
let changed = false;
|
||||
export function treeNodePinKey(node: TreeNode): string {
|
||||
if (!node.connectionId) return node.id;
|
||||
const identity = treeNodePinIdentity(node);
|
||||
const payload = [identity.database, identity.schema, identity.catalog, identity.type, identity.name, identity.signature, identity.id];
|
||||
return `${identity.connectionId}:pin:v2:${encodeURIComponent(JSON.stringify(payload))}`;
|
||||
}
|
||||
|
||||
export function parseTreeNodePinKey(key: string): PinnedTreeNodeIdentity | null {
|
||||
const marker = ":pin:v2:";
|
||||
const markerIndex = key.indexOf(marker);
|
||||
if (markerIndex <= 0) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeURIComponent(key.slice(markerIndex + marker.length)));
|
||||
if (!Array.isArray(payload) || payload.length !== 7 || payload.some((value) => typeof value !== "string")) return null;
|
||||
const [database, schema, catalog, type, name, signature, id] = payload;
|
||||
return { connectionId: key.slice(0, markerIndex), database, schema, catalog, type: type as TreeNode["type"], name, signature, id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePinnedTreeNodeOrder(ids: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
normalized.push(id);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function pinnedTreeNodeIdentityMatches(left: PinnedTreeNodeIdentity, right: PinnedTreeNodeIdentity, canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity): boolean {
|
||||
const canonicalLeft = canonicalize(left);
|
||||
const canonicalRight = canonicalize(right);
|
||||
return (
|
||||
canonicalLeft.connectionId === canonicalRight.connectionId &&
|
||||
canonicalLeft.database === canonicalRight.database &&
|
||||
canonicalLeft.schema === canonicalRight.schema &&
|
||||
canonicalLeft.catalog === canonicalRight.catalog &&
|
||||
canonicalLeft.type === canonicalRight.type &&
|
||||
canonicalLeft.name === canonicalRight.name &&
|
||||
canonicalLeft.signature === canonicalRight.signature
|
||||
);
|
||||
}
|
||||
|
||||
function pinnedTreeNodeOrderKeyMatchesNode(key: string, node: TreeNode, canonicalize: PinnedTreeNodeIdentityCanonicalizer, legacyKeys: ReadonlySet<string> = new Set()): boolean {
|
||||
if (key === treeNodePinKey(node) || key === node.id || legacyKeys.has(key)) return true;
|
||||
const identity = parseTreeNodePinKey(key);
|
||||
return !!identity && pinnedTreeNodeIdentityMatches(identity, treeNodePinIdentity(node), canonicalize);
|
||||
}
|
||||
|
||||
export function removePinnedTreeNodesFromOrder(order: readonly string[], nodes: readonly TreeNode[], canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): string[] {
|
||||
const removedKeys = new Set(legacyKeys);
|
||||
const removedIdentities: PinnedTreeNodeIdentity[] = [];
|
||||
const visited = new WeakSet<TreeNode>();
|
||||
const visit = (items: readonly TreeNode[]) => {
|
||||
for (const node of items) {
|
||||
if (visited.has(node)) continue;
|
||||
visited.add(node);
|
||||
// Remove the scoped key and any remaining legacy key. Keeping either lets
|
||||
// an object recreated with the same identity inherit a deleted pin.
|
||||
removedKeys.add(treeNodePinKey(node));
|
||||
removedKeys.add(node.id);
|
||||
removedIdentities.push(treeNodePinIdentity(node));
|
||||
if (node.children) visit(node.children);
|
||||
if (node.hiddenChildren) visit(node.hiddenChildren);
|
||||
}
|
||||
};
|
||||
|
||||
visit(nodes);
|
||||
return normalizePinnedTreeNodeOrder(order).filter((key) => {
|
||||
if (removedKeys.has(key)) return false;
|
||||
const identity = parseTreeNodePinKey(key);
|
||||
return !identity || !removedIdentities.some((removed) => pinnedTreeNodeIdentityMatches(identity, removed, canonicalize));
|
||||
});
|
||||
}
|
||||
|
||||
/** Replaces a pinned object identity in place after a successful rename. */
|
||||
export function replacePinnedTreeNodeInOrder(order: readonly string[], oldNode: TreeNode, newNode: TreeNode, canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): string[] {
|
||||
const normalized = normalizePinnedTreeNodeOrder(order);
|
||||
const legacyKeySet = new Set(legacyKeys);
|
||||
const oldIndex = normalized.findIndex((key) => pinnedTreeNodeOrderKeyMatchesNode(key, oldNode, canonicalize, legacyKeySet));
|
||||
if (oldIndex < 0) return normalized;
|
||||
|
||||
const shouldRemove = (key: string) => pinnedTreeNodeOrderKeyMatchesNode(key, oldNode, canonicalize, legacyKeySet) || pinnedTreeNodeOrderKeyMatchesNode(key, newNode, canonicalize);
|
||||
const replacementIndex = normalized.slice(0, oldIndex).filter((key) => !shouldRemove(key)).length;
|
||||
const next = normalized.filter((key) => !shouldRemove(key));
|
||||
next.splice(replacementIndex, 0, treeNodePinKey(newNode));
|
||||
return normalizePinnedTreeNodeOrder(next);
|
||||
}
|
||||
|
||||
export function migrateLegacyPinnedTreeNodeOrder(nodes: readonly TreeNode[], pinnedOrder: readonly string[]): { order: string[]; ids: Set<string>; changed: boolean } {
|
||||
const next = normalizePinnedTreeNodeOrder(pinnedOrder);
|
||||
let changed = next.length !== pinnedOrder.length;
|
||||
const visit = (items: readonly TreeNode[]) => {
|
||||
for (const node of items) {
|
||||
const pinKey = treeNodePinKey(node);
|
||||
if (pinKey !== node.id && next.has(node.id) && !next.has(pinKey)) {
|
||||
// A legacy id cannot distinguish colliding nodes. Claim it once for the
|
||||
// first matching loaded node, then persist the fully scoped identity.
|
||||
next.delete(node.id);
|
||||
next.add(pinKey);
|
||||
const legacyIndex = pinKey === node.id ? -1 : next.indexOf(node.id);
|
||||
if (legacyIndex >= 0) {
|
||||
const scopedIndex = next.indexOf(pinKey);
|
||||
if (scopedIndex < 0) {
|
||||
// Replace in place so upgrading a legacy key never changes the user's
|
||||
// persisted order. A colliding legacy id is claimed by the first
|
||||
// matching loaded node, matching the previous migration behavior.
|
||||
next[legacyIndex] = pinKey;
|
||||
} else {
|
||||
next.splice(legacyIndex, 1);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (node.children) visit(node.children);
|
||||
|
|
@ -45,7 +164,23 @@ export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinne
|
|||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return { ids: next, changed };
|
||||
const order = normalizePinnedTreeNodeOrder(next);
|
||||
return { order, ids: new Set(order), changed: changed || order.length !== next.length };
|
||||
}
|
||||
|
||||
export function migrateLegacyPinnedTreeNodeIds(nodes: readonly TreeNode[], pinnedIds: Set<string>): { ids: Set<string>; changed: boolean } {
|
||||
const migrated = migrateLegacyPinnedTreeNodeOrder(nodes, [...pinnedIds]);
|
||||
return { ids: migrated.ids, changed: migrated.changed };
|
||||
}
|
||||
|
||||
export function reorderPinnedTreeNodeOrder(order: readonly string[], draggedKey: string, targetKey: string, position: PinnedTreeNodeDropPosition): string[] {
|
||||
const normalized = normalizePinnedTreeNodeOrder(order);
|
||||
if (draggedKey === targetKey || !normalized.includes(draggedKey) || !normalized.includes(targetKey)) return normalized;
|
||||
|
||||
const next = normalized.filter((key) => key !== draggedKey);
|
||||
const targetIndex = next.indexOf(targetKey);
|
||||
next.splice(position === "before" ? targetIndex : targetIndex + 1, 0, draggedKey);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
|
||||
|
|
@ -60,6 +195,51 @@ export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean):
|
|||
return [...pinned, ...unpinned];
|
||||
}
|
||||
|
||||
function loadedTreeNodePinIdentities(nodes: readonly TreeNode[]): Map<string, PinnedTreeNodeIdentity> {
|
||||
const identities = new Map<string, PinnedTreeNodeIdentity>();
|
||||
const visited = new WeakSet<TreeNode>();
|
||||
const visit = (items: readonly TreeNode[]) => {
|
||||
for (const node of items) {
|
||||
if (visited.has(node)) continue;
|
||||
visited.add(node);
|
||||
const identity = treeNodePinIdentity(node);
|
||||
identities.set(treeNodePinKey(node), identity);
|
||||
if (!identities.has(node.id)) identities.set(node.id, identity);
|
||||
if (node.children) visit(node.children);
|
||||
if (node.hiddenChildren) visit(node.hiddenChildren);
|
||||
}
|
||||
};
|
||||
visit(nodes);
|
||||
return identities;
|
||||
}
|
||||
|
||||
export function orderItemsByPinnedTreeNodeOrder<T>(items: readonly T[], pinnedOrder: readonly string[], matches: (item: T, identity: PinnedTreeNodeIdentity) => boolean, loadedNodes: readonly TreeNode[] = []): T[] {
|
||||
const normalizedOrder = normalizePinnedTreeNodeOrder(pinnedOrder);
|
||||
if (!items.length || !normalizedOrder.length) return [...items];
|
||||
|
||||
let loadedIdentities: Map<string, PinnedTreeNodeIdentity> | undefined;
|
||||
const ranks: Array<number | undefined> = Array.from({ length: items.length });
|
||||
normalizedOrder.forEach((key, rank) => {
|
||||
const parsedIdentity = parseTreeNodePinKey(key);
|
||||
if (!parsedIdentity && !loadedIdentities) loadedIdentities = loadedTreeNodePinIdentities(loadedNodes);
|
||||
const identity = parsedIdentity ?? loadedIdentities?.get(key);
|
||||
if (!identity) return;
|
||||
items.forEach((item, index) => {
|
||||
if (ranks[index] === undefined && matches(item, identity)) ranks[index] = rank;
|
||||
});
|
||||
});
|
||||
|
||||
const ranked: Array<{ item: T; index: number; rank: number }> = [];
|
||||
const unpinned: T[] = [];
|
||||
items.forEach((item, index) => {
|
||||
const rank = ranks[index];
|
||||
if (rank === undefined) unpinned.push(item);
|
||||
else ranked.push({ item, index, rank });
|
||||
});
|
||||
ranked.sort((left, right) => left.rank - right.rank || left.index - right.index);
|
||||
return [...ranked.map(({ item }) => item), ...unpinned];
|
||||
}
|
||||
|
||||
function rememberNaturalTreeNodeOrder(nodes: readonly TreeNode[]): void {
|
||||
let nextOrder = 0;
|
||||
for (const node of nodes) {
|
||||
|
|
@ -76,18 +256,31 @@ function rememberNaturalTreeNodeOrder(nodes: readonly TreeNode[]): void {
|
|||
}
|
||||
}
|
||||
|
||||
export function orderPinnedTreeNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
export function orderPinnedTreeNodes(nodes: TreeNode[], pinnedOrder: readonly string[] = [], isFixedPriority: FixedTreeNodePriority = () => false): TreeNode[] {
|
||||
rememberNaturalTreeNodeOrder(nodes);
|
||||
const fixed: TreeNode[] = [];
|
||||
const pinned: TreeNode[] = [];
|
||||
const unpinned: TreeNode[] = [];
|
||||
const orderByKey = new Map(normalizePinnedTreeNodeOrder(pinnedOrder).map((key, index) => [key, index] as const));
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.pinned) pinned.push(node);
|
||||
if (isFixedPriority(node)) fixed.push(node);
|
||||
else if (node.pinned) pinned.push(node);
|
||||
else unpinned.push(node);
|
||||
}
|
||||
|
||||
unpinned.sort((left, right) => naturalTreeNodeOrder(left)! - naturalTreeNodeOrder(right)!);
|
||||
return [...pinned, ...unpinned];
|
||||
const naturalOrder = (left: TreeNode, right: TreeNode) => naturalTreeNodeOrder(left)! - naturalTreeNodeOrder(right)!;
|
||||
fixed.sort(naturalOrder);
|
||||
pinned.sort((left, right) => {
|
||||
const leftRank = orderByKey.get(treeNodePinKey(left));
|
||||
const rightRank = orderByKey.get(treeNodePinKey(right));
|
||||
if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank;
|
||||
if (leftRank !== undefined) return -1;
|
||||
if (rightRank !== undefined) return 1;
|
||||
return naturalOrder(left, right);
|
||||
});
|
||||
unpinned.sort(naturalOrder);
|
||||
return [...fixed, ...pinned, ...unpinned];
|
||||
}
|
||||
|
||||
function findTreeNodeLocation(nodes: TreeNode[], target: TreeNode, parent: TreeNode | null = null): { node: TreeNode; parent: TreeNode | null } | null {
|
||||
|
|
@ -119,7 +312,7 @@ export function updatePinnedTreeNodeInPlace(nodes: TreeNode[], target: TreeNode,
|
|||
return "root";
|
||||
}
|
||||
|
||||
function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode {
|
||||
function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, pinnedOrder: readonly string[], isFixedPriority: FixedTreeNodePriority, clones: WeakMap<TreeNode, TreeNode>): TreeNode {
|
||||
const existing = clones.get(node);
|
||||
if (existing) return existing;
|
||||
const clone: TreeNode = {
|
||||
|
|
@ -128,37 +321,41 @@ function clonePinnedTreeNode(node: TreeNode, pinnedIds: Set<string>, clones: Wea
|
|||
};
|
||||
clones.set(node, clone);
|
||||
inheritNaturalTreeNodeOrder(node, clone);
|
||||
if (node.children) clone.children = applyPinnedTreeNodeStateInternal(node.children, pinnedIds, clones);
|
||||
if (node.hiddenChildren) clone.hiddenChildren = applyPinnedTreeNodeStateInternal(node.hiddenChildren, pinnedIds, clones);
|
||||
if (node.children) clone.children = applyPinnedTreeNodeStateInternal(node.children, pinnedIds, pinnedOrder, isFixedPriority, clones);
|
||||
if (node.hiddenChildren) clone.hiddenChildren = applyPinnedTreeNodeStateInternal(node.hiddenChildren, pinnedIds, pinnedOrder, isFixedPriority, clones);
|
||||
return clone;
|
||||
}
|
||||
|
||||
function applyPinnedTreeNodeStateInternal(nodes: TreeNode[], pinnedIds: Set<string>, clones: WeakMap<TreeNode, TreeNode>): TreeNode[] {
|
||||
function applyPinnedTreeNodeStateInternal(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[], isFixedPriority: FixedTreeNodePriority, clones: WeakMap<TreeNode, TreeNode>): TreeNode[] {
|
||||
rememberNaturalTreeNodeOrder(nodes);
|
||||
return orderPinnedTreeNodes(nodes.map((node) => clonePinnedTreeNode(node, pinnedIds, clones)));
|
||||
return orderPinnedTreeNodes(
|
||||
nodes.map((node) => clonePinnedTreeNode(node, pinnedIds, pinnedOrder, isFixedPriority, clones)),
|
||||
pinnedOrder,
|
||||
isFixedPriority,
|
||||
);
|
||||
}
|
||||
|
||||
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>): TreeNode[] {
|
||||
return applyPinnedTreeNodeStateInternal(nodes, pinnedIds, new WeakMap());
|
||||
export function applyPinnedTreeNodeState(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[] = [...pinnedIds], isFixedPriority: FixedTreeNodePriority = () => false): TreeNode[] {
|
||||
return applyPinnedTreeNodeStateInternal(nodes, pinnedIds, pinnedOrder, isFixedPriority, new WeakMap());
|
||||
}
|
||||
|
||||
function syncPinnedTreeNodeStateInPlaceInternal(nodes: TreeNode[], pinnedIds: Set<string>, visited: WeakSet<TreeNode>): void {
|
||||
function syncPinnedTreeNodeStateInPlaceInternal(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[], isFixedPriority: FixedTreeNodePriority, visited: WeakSet<TreeNode>): void {
|
||||
for (const node of nodes) {
|
||||
if (visited.has(node)) continue;
|
||||
visited.add(node);
|
||||
node.pinned = pinnedIds.has(treeNodePinKey(node)) || pinnedIds.has(node.id);
|
||||
if (node.children) {
|
||||
syncPinnedTreeNodeStateInPlaceInternal(node.children, pinnedIds, visited);
|
||||
node.children = orderPinnedTreeNodes(node.children);
|
||||
syncPinnedTreeNodeStateInPlaceInternal(node.children, pinnedIds, pinnedOrder, isFixedPriority, visited);
|
||||
node.children = orderPinnedTreeNodes(node.children, pinnedOrder, isFixedPriority);
|
||||
}
|
||||
if (node.hiddenChildren) {
|
||||
syncPinnedTreeNodeStateInPlaceInternal(node.hiddenChildren, pinnedIds, visited);
|
||||
node.hiddenChildren = orderPinnedTreeNodes(node.hiddenChildren);
|
||||
syncPinnedTreeNodeStateInPlaceInternal(node.hiddenChildren, pinnedIds, pinnedOrder, isFixedPriority, visited);
|
||||
node.hiddenChildren = orderPinnedTreeNodes(node.hiddenChildren, pinnedOrder, isFixedPriority);
|
||||
}
|
||||
}
|
||||
nodes.splice(0, nodes.length, ...orderPinnedTreeNodes(nodes));
|
||||
nodes.splice(0, nodes.length, ...orderPinnedTreeNodes(nodes, pinnedOrder, isFixedPriority));
|
||||
}
|
||||
|
||||
export function syncPinnedTreeNodeStateInPlace(nodes: TreeNode[], pinnedIds: Set<string>): void {
|
||||
syncPinnedTreeNodeStateInPlaceInternal(nodes, pinnedIds, new WeakSet());
|
||||
export function syncPinnedTreeNodeStateInPlace(nodes: TreeNode[], pinnedIds: Set<string>, pinnedOrder: readonly string[] = [...pinnedIds], isFixedPriority: FixedTreeNodePriority = () => false): void {
|
||||
syncPinnedTreeNodeStateInPlaceInternal(nodes, pinnedIds, pinnedOrder, isFixedPriority, new WeakSet());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ObjectInfo } from "@/types/database";
|
||||
import { compareDatabaseObjectNames, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
|
||||
import type { ObjectInfo, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import { pinnedTreeNodeIdentityMatches, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
|
||||
import { buildGroupedObjectTreeNodes, buildSimpleObjectTreeNodes, buildTableTreeNodes, compareDatabaseObjectNames, normalizeDatabaseObjectName } from "@/lib/table/tableTree";
|
||||
import { parseSlashDelimitedRegexQuery } from "@/lib/common/searchPattern";
|
||||
|
||||
export type ObjectBrowserRow = {
|
||||
|
|
@ -26,6 +27,114 @@ export type ObjectBrowserSortDirection = "asc" | "desc";
|
|||
export type ObjectBrowserFilter = "all" | "tables" | "views" | "materializedViews" | "procedures" | "functions" | "triggers" | "sequences" | "packages" | "types";
|
||||
export type ObjectBrowserFilterCounts = Record<ObjectBrowserFilter, number>;
|
||||
|
||||
export type ObjectBrowserPinnedTreeNodeContext = {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
catalog?: string;
|
||||
sidebarParentId?: string;
|
||||
};
|
||||
|
||||
export function objectBrowserRowTreeNodeType(type: ObjectBrowserRow["type"]): TreeNodeType {
|
||||
if (type === "TABLE") return "table";
|
||||
if (type === "VIEW") return "view";
|
||||
if (type === "MATERIALIZED_VIEW") return "materialized_view";
|
||||
if (type === "PROCEDURE") return "procedure";
|
||||
if (type === "FUNCTION") return "function";
|
||||
if (type === "TRIGGER") return "trigger";
|
||||
if (type === "SEQUENCE") return "sequence";
|
||||
if (type === "PACKAGE_BODY") return "package-body";
|
||||
if (type === "PACKAGE") return "package";
|
||||
if (type === "TYPE_BODY") return "type-body";
|
||||
return "type";
|
||||
}
|
||||
|
||||
export function canonicalObjectBrowserPinnedTreeNodeIdentity(identity: PinnedTreeNodeIdentity, database: string): PinnedTreeNodeIdentity {
|
||||
// Some drivers expose database-level objects with the selected database as
|
||||
// their schema, while the sidebar represents the same objects without one.
|
||||
// Canonicalize only this alias; real schemas remain distinct.
|
||||
return identity.schema === database ? { ...identity, schema: "" } : identity;
|
||||
}
|
||||
|
||||
export function canonicalizeObjectBrowserPinnedTreeNodeIdentity(context: ObjectBrowserPinnedTreeNodeContext): (identity: PinnedTreeNodeIdentity) => PinnedTreeNodeIdentity {
|
||||
return (identity) => canonicalObjectBrowserPinnedTreeNodeIdentity(identity, context.database);
|
||||
}
|
||||
|
||||
export function objectBrowserRowPinnedTreeNodeIdentity(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): PinnedTreeNodeIdentity {
|
||||
return {
|
||||
connectionId: context.connectionId,
|
||||
database: context.database,
|
||||
schema: row.schema ?? context.schema ?? "",
|
||||
catalog: context.catalog || "",
|
||||
type: objectBrowserRowTreeNodeType(row.type),
|
||||
name: row.name,
|
||||
signature: row.type === "FUNCTION" || row.type === "PROCEDURE" ? row.signature?.trim() || "" : "",
|
||||
id: row.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function objectBrowserRowMatchesPinnedTreeNode(row: ObjectBrowserRow, identity: PinnedTreeNodeIdentity, context: ObjectBrowserPinnedTreeNodeContext): boolean {
|
||||
return pinnedTreeNodeIdentityMatches(identity, objectBrowserRowPinnedTreeNodeIdentity(row, context), canonicalizeObjectBrowserPinnedTreeNodeIdentity(context));
|
||||
}
|
||||
|
||||
function flattenTreeNodeIds(nodes: readonly TreeNode[]): string[] {
|
||||
return nodes.flatMap((node) => [node.id, ...(node.children ? flattenTreeNodeIds(node.children) : [])]);
|
||||
}
|
||||
|
||||
function objectBrowserRowObjectInfo(row: ObjectBrowserRow, schema?: string): ObjectInfo {
|
||||
return {
|
||||
name: row.name,
|
||||
object_type: row.type,
|
||||
schema,
|
||||
signature: row.signature || undefined,
|
||||
parent_schema: row.partitionParentSchema,
|
||||
parent_name: row.partitionParentName,
|
||||
};
|
||||
}
|
||||
|
||||
function legacySchemaCandidates(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): Array<string | undefined> {
|
||||
const identity = objectBrowserRowPinnedTreeNodeIdentity(row, context);
|
||||
const canonicalIdentity = canonicalizeObjectBrowserPinnedTreeNodeIdentity(context)(identity);
|
||||
return [...new Set([row.schema, context.schema, canonicalIdentity.schema, undefined].map((schema) => schema?.trim() || undefined))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the historical bare IDs emitted by each sidebar layout. The current
|
||||
* v2 key is identity-based, but old persisted pins contain only these IDs.
|
||||
*/
|
||||
export function objectBrowserRowLegacyPinnedTreeNodeIds(row: ObjectBrowserRow, context: ObjectBrowserPinnedTreeNodeContext): string[] {
|
||||
const parentId = context.sidebarParentId || `${context.connectionId}:${context.database}`;
|
||||
const legacyIds = new Set<string>();
|
||||
for (const schema of legacySchemaCandidates(row, context)) {
|
||||
const object = objectBrowserRowObjectInfo(row, schema);
|
||||
const simpleNodes =
|
||||
row.type === "TABLE" || row.type === "VIEW" || row.type === "MATERIALIZED_VIEW"
|
||||
? buildTableTreeNodes({
|
||||
nodeId: parentId,
|
||||
connectionId: context.connectionId,
|
||||
database: context.database,
|
||||
schema,
|
||||
tables: [
|
||||
{
|
||||
name: row.name,
|
||||
table_type: row.type,
|
||||
comment: row.comment,
|
||||
parent_schema: row.partitionParentSchema,
|
||||
parent_name: row.partitionParentName,
|
||||
},
|
||||
],
|
||||
})
|
||||
: buildSimpleObjectTreeNodes({ nodeId: parentId, connectionId: context.connectionId, database: context.database, schema, objects: [object] });
|
||||
for (const id of flattenTreeNodeIds(simpleNodes)) legacyIds.add(id);
|
||||
|
||||
const groupedNodes = buildGroupedObjectTreeNodes({ nodeId: parentId, connectionId: context.connectionId, database: context.database, schema, objects: [object] });
|
||||
for (const group of groupedNodes) {
|
||||
for (const id of flattenTreeNodeIds(group.children || [])) legacyIds.add(id);
|
||||
}
|
||||
}
|
||||
return [...legacyIds];
|
||||
}
|
||||
|
||||
export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] {
|
||||
const value = type.toUpperCase();
|
||||
const normalized = value.replace(/[\s-]+/g, "_");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function tableNode(name = "users"): TreeNode {
|
||||
return {
|
||||
id: `conn:db:public:${name}`,
|
||||
label: name,
|
||||
type: "table",
|
||||
connectionId: "conn",
|
||||
database: "db",
|
||||
schema: "public",
|
||||
tableName: name,
|
||||
};
|
||||
}
|
||||
|
||||
describe("connectionStore pinned tree node removal", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("does not pin a new table that reuses a deleted pinned table identity", async () => {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const deletedTable = tableNode();
|
||||
store.treeNodes = [
|
||||
{
|
||||
id: "conn",
|
||||
label: "Connection",
|
||||
type: "connection",
|
||||
connectionId: "conn",
|
||||
children: [deletedTable],
|
||||
},
|
||||
];
|
||||
|
||||
store.toggleTreeNodePin(deletedTable);
|
||||
expect(store.isTreeNodePinned(deletedTable)).toBe(true);
|
||||
|
||||
store.removeTreeNode(deletedTable.id);
|
||||
const replacement = tableNode();
|
||||
store.treeNodes[0].children = [replacement];
|
||||
|
||||
expect(store.isTreeNodePinned(replacement)).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes desktop pin saves so an older reorder cannot overwrite the latest one", async () => {
|
||||
const savePinnedTreeNodeIds = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.doMock("@/lib/backend/api", () => ({ savePinnedTreeNodeIds }));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const users = tableNode("users");
|
||||
const orders = tableNode("orders");
|
||||
store.treeNodes = [{ id: "conn", label: "Connection", type: "connection", connectionId: "conn", children: [users, orders] }];
|
||||
store.toggleTreeNodePin(users);
|
||||
store.toggleTreeNodePin(orders);
|
||||
await vi.waitFor(() => expect(savePinnedTreeNodeIds).toHaveBeenCalledTimes(2));
|
||||
|
||||
savePinnedTreeNodeIds.mockClear();
|
||||
const snapshots: string[][] = [];
|
||||
const resolvers: Array<() => void> = [];
|
||||
savePinnedTreeNodeIds.mockImplementation((ids: string[]) => {
|
||||
snapshots.push([...ids]);
|
||||
return new Promise<void>((resolve) => resolvers.push(resolve));
|
||||
});
|
||||
|
||||
const usersKey = treeNodePinKey(users);
|
||||
const ordersKey = treeNodePinKey(orders);
|
||||
expect(store.reorderPinnedTreeNodes(usersKey, ordersKey, "after")).toBe(true);
|
||||
await vi.waitFor(() => expect(savePinnedTreeNodeIds).toHaveBeenCalledTimes(1));
|
||||
expect(store.reorderPinnedTreeNodes(ordersKey, usersKey, "after")).toBe(true);
|
||||
|
||||
expect(savePinnedTreeNodeIds).toHaveBeenCalledTimes(1);
|
||||
resolvers[0]!();
|
||||
await vi.waitFor(() => expect(savePinnedTreeNodeIds).toHaveBeenCalledTimes(2));
|
||||
resolvers[1]!();
|
||||
|
||||
expect(snapshots).toEqual([
|
||||
[ordersKey, usersKey],
|
||||
[usersKey, ordersKey],
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves a renamed pinned object to its new identity so recreating the old name is unpinned", async () => {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const users = tableNode("users");
|
||||
const accounts = tableNode("accounts");
|
||||
store.treeNodes = [{ id: "conn", label: "Connection", type: "connection", connectionId: "conn", children: [users] }];
|
||||
store.toggleTreeNodePin(users);
|
||||
|
||||
store.treeNodes[0].children = [accounts];
|
||||
store.replacePinnedTreeNode(users, accounts);
|
||||
|
||||
const recreatedUsers = tableNode("users");
|
||||
store.treeNodes[0].children = [accounts, recreatedUsers];
|
||||
|
||||
expect(store.isTreeNodePinned(accounts)).toBe(true);
|
||||
expect(store.isTreeNodePinned(recreatedUsers)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the old pin when a renamed replacement is not loaded in the sidebar", async () => {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const users = tableNode("users");
|
||||
const accounts = tableNode("accounts");
|
||||
store.treeNodes = [{ id: "conn", label: "Connection", type: "connection", connectionId: "conn", children: [users] }];
|
||||
store.toggleTreeNodePin(users);
|
||||
|
||||
expect(store.replacePinnedTreeNode(users, accounts)).toBe(true);
|
||||
expect(store.isTreeNodePinned(users)).toBe(false);
|
||||
expect(store.isTreeNodePinned(accounts)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,21 @@ import type {
|
|||
TunnelProfile,
|
||||
VectorCollectionMeta,
|
||||
} from "@/types/database";
|
||||
import { inheritNaturalTreeNodeOrder, migrateLegacyPinnedTreeNodeIds, syncPinnedTreeNodeStateInPlace, treeNodePinKey } from "@/lib/app/pinnedItems";
|
||||
import {
|
||||
inheritNaturalTreeNodeOrder,
|
||||
migrateLegacyPinnedTreeNodeOrder,
|
||||
normalizePinnedTreeNodeOrder,
|
||||
orderItemsByPinnedTreeNodeOrder,
|
||||
pinnedTreeNodeIdentityMatches,
|
||||
removePinnedTreeNodesFromOrder,
|
||||
reorderPinnedTreeNodeOrder,
|
||||
replacePinnedTreeNodeInOrder,
|
||||
syncPinnedTreeNodeStateInPlace,
|
||||
treeNodePinIdentity,
|
||||
treeNodePinKey,
|
||||
type PinnedTreeNodeIdentity,
|
||||
type PinnedTreeNodeIdentityCanonicalizer,
|
||||
} from "@/lib/app/pinnedItems";
|
||||
import {
|
||||
reconcileLayout,
|
||||
buildTreeNodesFromLayout,
|
||||
|
|
@ -78,7 +92,6 @@ import {
|
|||
import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNodeSchemaCachePrefix } from "@/lib/sidebar/treeNodeContext";
|
||||
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache";
|
||||
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
|
||||
import { prunePinnedTreeNodeIdsForConnection } from "@/lib/app/pinnedTreeNodeIds";
|
||||
import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
|
||||
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -304,7 +317,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const sidebarDatabaseStorageInFlight = new Map<string, Promise<DatabaseStorageInfo[]>>();
|
||||
const sidebarTableStorageCache = new Map<string, { expiresAt: number; value: ObjectStatistics[] }>();
|
||||
const sidebarTableStorageInFlight = new Map<string, Promise<ObjectStatistics[]>>();
|
||||
const pinnedTreeNodeOrder = ref<string[]>([]);
|
||||
const pinnedTreeNodeIds = ref<Set<string>>(new Set());
|
||||
let pinnedTreeNodePersistQueue: Promise<void> = Promise.resolve();
|
||||
const connectedIds = ref<Set<string>>(new Set());
|
||||
const identifierQuotes = ref<Record<string, string>>({});
|
||||
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
|
||||
|
|
@ -1002,27 +1017,27 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
};
|
||||
}
|
||||
|
||||
function loadPinnedTreeNodeIdsFromLocalStorage(): Set<string> {
|
||||
function loadPinnedTreeNodeOrderFromLocalStorage(): string[] {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return new Set();
|
||||
if (typeof localStorage === "undefined") return [];
|
||||
const saved = localStorage.getItem(PINNED_TREE_NODES_STORAGE_KEY);
|
||||
const ids = saved ? JSON.parse(saved) : [];
|
||||
return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : []);
|
||||
return normalizePinnedTreeNodeOrder(Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPinnedTreeNodeIds(): Promise<Set<string>> {
|
||||
if (!isDesktop) return loadPinnedTreeNodeIdsFromLocalStorage();
|
||||
async function loadPinnedTreeNodeOrder(): Promise<string[]> {
|
||||
if (!isDesktop) return loadPinnedTreeNodeOrderFromLocalStorage();
|
||||
const ids = await api.loadPinnedTreeNodeIds().catch(() => []);
|
||||
const valid = ids.filter((id) => typeof id === "string");
|
||||
if (valid.length > 0) return new Set(valid);
|
||||
const valid = normalizePinnedTreeNodeOrder(ids.filter((id): id is string => typeof id === "string"));
|
||||
if (valid.length > 0) return valid;
|
||||
|
||||
// Migrate legacy localStorage values for existing desktop users.
|
||||
const legacy = loadPinnedTreeNodeIdsFromLocalStorage();
|
||||
if (legacy.size > 0) {
|
||||
await api.savePinnedTreeNodeIds([...legacy]).catch(() => undefined);
|
||||
const legacy = loadPinnedTreeNodeOrderFromLocalStorage();
|
||||
if (legacy.length > 0) {
|
||||
await api.savePinnedTreeNodeIds(legacy).catch(() => undefined);
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(PINNED_TREE_NODES_STORAGE_KEY);
|
||||
}
|
||||
|
|
@ -1030,18 +1045,53 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return legacy;
|
||||
}
|
||||
|
||||
function setPinnedTreeNodeOrder(order: readonly string[]) {
|
||||
const normalized = normalizePinnedTreeNodeOrder(order);
|
||||
pinnedTreeNodeOrder.value = normalized;
|
||||
pinnedTreeNodeIds.value = new Set(normalized);
|
||||
}
|
||||
|
||||
function persistPinnedTreeNodeIds() {
|
||||
const snapshot = [...pinnedTreeNodeOrder.value];
|
||||
if (isDesktop) {
|
||||
void api.savePinnedTreeNodeIds([...pinnedTreeNodeIds.value]).catch(() => undefined);
|
||||
// A later drag must never be persisted before an earlier request finishes:
|
||||
// otherwise a slow old request can overwrite the final ordering on disk.
|
||||
pinnedTreeNodePersistQueue = pinnedTreeNodePersistQueue.catch(() => undefined).then(() => api.savePinnedTreeNodeIds(snapshot).catch(() => undefined));
|
||||
return;
|
||||
}
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify([...pinnedTreeNodeIds.value]));
|
||||
localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
|
||||
function findLoadedTreeNodeById(nodes: readonly TreeNode[], id: string): TreeNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
const child = node.children ? findLoadedTreeNodeById(node.children, id) : null;
|
||||
if (child) return child;
|
||||
const hiddenChild = node.hiddenChildren ? findLoadedTreeNodeById(node.hiddenChildren, id) : null;
|
||||
if (hiddenChild) return hiddenChild;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTreeNodePinned(node: TreeNode | string): boolean {
|
||||
if (typeof node === "string") return pinnedTreeNodeIds.value.has(node);
|
||||
return pinnedTreeNodeIds.value.has(treeNodePinKey(node)) || pinnedTreeNodeIds.value.has(node.id);
|
||||
if (typeof node !== "string") return pinnedTreeNodeIds.value.has(treeNodePinKey(node)) || pinnedTreeNodeIds.value.has(node.id);
|
||||
if (pinnedTreeNodeIds.value.has(node)) return true;
|
||||
const loadedNode = findLoadedTreeNodeById(treeNodes.value, node);
|
||||
return !!loadedNode && pinnedTreeNodeIds.value.has(treeNodePinKey(loadedNode));
|
||||
}
|
||||
|
||||
function isFixedPriorityTreeNode(node: TreeNode): boolean {
|
||||
if (node.type !== "database" && node.type !== "redis-db" && node.type !== "mongo-db") return false;
|
||||
return !!node.connectionId && typeof node.database === "string" && isDefaultDatabase(node.connectionId, node.database);
|
||||
}
|
||||
|
||||
function orderByPinnedTreeNodes<T>(items: readonly T[], matches: (item: T, identity: PinnedTreeNodeIdentity) => boolean): T[] {
|
||||
return orderItemsByPinnedTreeNodeOrder(items, pinnedTreeNodeOrder.value, matches, treeNodes.value);
|
||||
}
|
||||
|
||||
function syncPinnedTreeState(nodes: TreeNode[]) {
|
||||
syncPinnedTreeNodeStateInPlace(nodes, pinnedTreeNodeIds.value, pinnedTreeNodeOrder.value, isFixedPriorityTreeNode);
|
||||
}
|
||||
|
||||
function isConnectionUtilityNode(node: TreeNode): boolean {
|
||||
|
|
@ -1110,17 +1160,43 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return child;
|
||||
});
|
||||
}
|
||||
const migratedPins = migrateLegacyPinnedTreeNodeIds(children, pinnedTreeNodeIds.value);
|
||||
const migratedPins = migrateLegacyPinnedTreeNodeOrder(children, pinnedTreeNodeOrder.value);
|
||||
if (migratedPins.changed) {
|
||||
pinnedTreeNodeIds.value = migratedPins.ids;
|
||||
setPinnedTreeNodeOrder(migratedPins.order);
|
||||
persistPinnedTreeNodeIds();
|
||||
}
|
||||
syncPinnedTreeNodeStateInPlace(children, migratedPins.ids);
|
||||
syncPinnedTreeState(children);
|
||||
parent.children = markRawLeafTreeNodes(children);
|
||||
loadedTreeNodeChildrenIds.value.add(parent.id);
|
||||
}
|
||||
|
||||
function removePinnedTreeNodes(nodes: readonly TreeNode[], canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean {
|
||||
const nextPinnedOrder = removePinnedTreeNodesFromOrder(pinnedTreeNodeOrder.value, nodes, canonicalize, legacyKeys);
|
||||
if (nextPinnedOrder.length === pinnedTreeNodeOrder.value.length && nextPinnedOrder.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false;
|
||||
setPinnedTreeNodeOrder(nextPinnedOrder);
|
||||
syncPinnedTreeState(treeNodes.value);
|
||||
persistPinnedTreeNodeIds();
|
||||
return true;
|
||||
}
|
||||
|
||||
function replacePinnedTreeNode(oldNode: TreeNode, newNode: TreeNode, canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean {
|
||||
// Use the freshly loaded sidebar node when available so the persisted key
|
||||
// carries its real id, not the id of the pre-rename object.
|
||||
const loadedReplacement = findTreeNodes(treeNodes.value, (node) => pinnedTreeNodeIdentityMatches(treeNodePinIdentity(node), treeNodePinIdentity(newNode), canonicalize))[0];
|
||||
// A caller may provide a virtual row while the sidebar object is unloaded;
|
||||
// persisting that row id would create a pin that the sidebar cannot restore.
|
||||
const nextPinnedOrder = loadedReplacement ? replacePinnedTreeNodeInOrder(pinnedTreeNodeOrder.value, oldNode, loadedReplacement, canonicalize, legacyKeys) : removePinnedTreeNodesFromOrder(pinnedTreeNodeOrder.value, [oldNode], canonicalize, legacyKeys);
|
||||
if (nextPinnedOrder.length === pinnedTreeNodeOrder.value.length && nextPinnedOrder.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false;
|
||||
setPinnedTreeNodeOrder(nextPinnedOrder);
|
||||
syncPinnedTreeState(treeNodes.value);
|
||||
persistPinnedTreeNodeIds();
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeTreeNode(nodeId: string) {
|
||||
const node = findNode(treeNodes.value, nodeId);
|
||||
if (node) removePinnedTreeNodes([node]);
|
||||
|
||||
const parent = findParentNode(treeNodes.value, nodeId);
|
||||
if (parent?.children) {
|
||||
parent.children = parent.children.filter((c) => c.id !== nodeId);
|
||||
|
|
@ -1816,19 +1892,53 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
|
||||
function toggleTreeNodePin(node: TreeNode) {
|
||||
const pinKey = treeNodePinKey(node);
|
||||
const next = new Set(pinnedTreeNodeIds.value);
|
||||
const wasPinned = next.has(pinKey) || next.has(node.id);
|
||||
const wasPinned = pinnedTreeNodeIds.value.has(pinKey) || pinnedTreeNodeIds.value.has(node.id);
|
||||
// Remove the legacy bare id as part of every toggle so old ambiguous pins
|
||||
// cannot continue matching objects in a different database.
|
||||
next.delete(node.id);
|
||||
if (wasPinned) next.delete(pinKey);
|
||||
else next.add(pinKey);
|
||||
pinnedTreeNodeIds.value = next;
|
||||
// cannot continue matching objects in a different database. Newly pinned
|
||||
// nodes append to the persisted order, placing them last in their sibling
|
||||
// pin section until the user explicitly reorders them.
|
||||
const next = pinnedTreeNodeOrder.value.filter((id) => id !== node.id && id !== pinKey);
|
||||
if (!wasPinned) next.push(pinKey);
|
||||
setPinnedTreeNodeOrder(next);
|
||||
persistPinnedTreeNodeIds();
|
||||
|
||||
// Pinning is infrequent; synchronizing the loaded tree here also clears any
|
||||
// stale flags created by legacy unscoped ids without rebuilding metadata.
|
||||
syncPinnedTreeNodeStateInPlace(treeNodes.value, next);
|
||||
syncPinnedTreeState(treeNodes.value);
|
||||
}
|
||||
|
||||
function findPinnedTreeNodeLocation(nodes: TreeNode[], pinKey: string): { node: TreeNode; siblings: TreeNode[] } | null {
|
||||
for (const node of nodes) {
|
||||
if (treeNodePinKey(node) === pinKey) return { node, siblings: nodes };
|
||||
if (node.children) {
|
||||
const found = findPinnedTreeNodeLocation(node.children, pinKey);
|
||||
if (found) return found;
|
||||
}
|
||||
if (node.hiddenChildren) {
|
||||
const found = findPinnedTreeNodeLocation(node.hiddenChildren, pinKey);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canReorderPinnedTreeNodes(draggedKey: string, targetKey: string): boolean {
|
||||
if (!draggedKey || !targetKey || draggedKey === targetKey) return false;
|
||||
const dragged = findPinnedTreeNodeLocation(treeNodes.value, draggedKey);
|
||||
const target = findPinnedTreeNodeLocation(treeNodes.value, targetKey);
|
||||
if (!dragged || !target || dragged.siblings !== target.siblings) return false;
|
||||
if (!isTreeNodePinned(dragged.node) || !isTreeNodePinned(target.node)) return false;
|
||||
return !isFixedPriorityTreeNode(dragged.node) && !isFixedPriorityTreeNode(target.node);
|
||||
}
|
||||
|
||||
function reorderPinnedTreeNodes(draggedKey: string, targetKey: string, position: DropPosition): boolean {
|
||||
if (position === "inside" || !canReorderPinnedTreeNodes(draggedKey, targetKey)) return false;
|
||||
const next = reorderPinnedTreeNodeOrder(pinnedTreeNodeOrder.value, draggedKey, targetKey, position);
|
||||
if (next.length === pinnedTreeNodeOrder.value.length && next.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false;
|
||||
setPinnedTreeNodeOrder(next);
|
||||
syncPinnedTreeState(treeNodes.value);
|
||||
persistPinnedTreeNodeIds();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function addConnection(config: ConnectionConfig, targetGroupId?: string | null) {
|
||||
|
|
@ -1942,9 +2052,12 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const nextConnections = connections.value.filter((c) => !removedIds.has(c.id));
|
||||
await persistConnections(nextConnections);
|
||||
connections.value = nextConnections;
|
||||
let nextPinnedOrder = pinnedTreeNodeOrder.value;
|
||||
for (const id of removedIds) {
|
||||
pinnedTreeNodeIds.value = prunePinnedTreeNodeIdsForConnection(pinnedTreeNodeIds.value, id);
|
||||
const prefix = `${id}:`;
|
||||
nextPinnedOrder = nextPinnedOrder.filter((pinId) => pinId !== id && !pinId.startsWith(prefix));
|
||||
}
|
||||
setPinnedTreeNodeOrder(nextPinnedOrder);
|
||||
persistPinnedTreeNodeIds();
|
||||
removeSidebarTableNameFiltersForConnections(removedIds);
|
||||
for (const id of removedIds) {
|
||||
|
|
@ -5450,7 +5563,14 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
}
|
||||
return node;
|
||||
});
|
||||
treeNodes.value = mergeState(freshNodes);
|
||||
const mergedNodes = mergeState(freshNodes);
|
||||
const migratedPins = migrateLegacyPinnedTreeNodeOrder(mergedNodes, pinnedTreeNodeOrder.value);
|
||||
if (migratedPins.changed) {
|
||||
setPinnedTreeNodeOrder(migratedPins.order);
|
||||
persistPinnedTreeNodeIds();
|
||||
}
|
||||
syncPinnedTreeState(mergedNodes);
|
||||
treeNodes.value = mergedNodes;
|
||||
}
|
||||
|
||||
function updateLayoutAndRebuild(nextLayout: SidebarLayout) {
|
||||
|
|
@ -5899,8 +6019,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
async function initFromDisk() {
|
||||
if (!initFromDiskPromise) {
|
||||
initFromDiskPromise = (async () => {
|
||||
const [pinnedIds, saved] = await Promise.all([loadPinnedTreeNodeIds(), api.loadConnections(), tunnelProfileStore.init()]);
|
||||
pinnedTreeNodeIds.value = pinnedIds;
|
||||
const [pinnedOrder, saved] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init()]);
|
||||
setPinnedTreeNodeOrder(pinnedOrder);
|
||||
connections.value = saved.map(normalizeConnection);
|
||||
const savedLayout = await api.loadSidebarLayout();
|
||||
const currentLayout = sidebarLayout.value.groups.length || sidebarLayout.value.order.length ? sidebarLayout.value : null;
|
||||
|
|
@ -5936,6 +6056,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
connectionMultiSelectActive,
|
||||
treeClipboard,
|
||||
treeNodes,
|
||||
removePinnedTreeNodes,
|
||||
replacePinnedTreeNode,
|
||||
removeTreeNode,
|
||||
refreshAllTree,
|
||||
collapseAllTreeNodes,
|
||||
|
|
@ -5956,7 +6078,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
getConfig,
|
||||
connectionIdentifierQuote,
|
||||
isTreeNodePinned,
|
||||
orderByPinnedTreeNodes,
|
||||
toggleTreeNodePin,
|
||||
canReorderPinnedTreeNodes,
|
||||
reorderPinnedTreeNodes,
|
||||
addConnection,
|
||||
copyConnectionsToTreeClipboard,
|
||||
pasteConnectionClipboard,
|
||||
|
|
|
|||
Loading…
Reference in New Issue