505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
import type { ConnectionConfig, ConnectionGroup, SidebarLayout, SidebarOrderEntry, TreeNode } from "@/types/database";
|
|
import { uuid } from "@/lib/common/utils";
|
|
import { orderPinnedTreeNodes } from "@/lib/app/pinnedItems";
|
|
|
|
export function emptyLayout(): SidebarLayout {
|
|
return { groups: [], order: [] };
|
|
}
|
|
|
|
export function hasSidebarLayoutEntries(layout: SidebarLayout | null | undefined): layout is SidebarLayout {
|
|
return !!layout && (layout.groups.length > 0 || layout.order.length > 0);
|
|
}
|
|
|
|
function folderPathSegments(path: string | undefined): string[] {
|
|
return (path ?? "").split("/").filter((segment) => segment.length > 0);
|
|
}
|
|
|
|
export function buildSidebarLayoutFromFolderPaths(connectionIds: string[], folderPaths: Iterable<string>, connectionFolderPaths: ReadonlyMap<string, string>): SidebarLayout | undefined {
|
|
const groups: ConnectionGroup[] = [];
|
|
const order: SidebarOrderEntry[] = [];
|
|
const groupEntries = new Map<string, Extract<SidebarOrderEntry, { type: "group" }>>();
|
|
|
|
const ensureFolder = (path: string | undefined) => {
|
|
const segments = folderPathSegments(path);
|
|
let parentEntry: Extract<SidebarOrderEntry, { type: "group" }> | undefined;
|
|
let currentPath = "";
|
|
|
|
for (const segment of segments) {
|
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
let entry = groupEntries.get(currentPath);
|
|
if (!entry) {
|
|
const groupId = uuid();
|
|
entry = { type: "group", id: groupId, children: [] };
|
|
groupEntries.set(currentPath, entry);
|
|
groups.push({ id: groupId, name: segment, collapsed: false });
|
|
if (parentEntry) parentEntry.children!.push(entry);
|
|
else order.push(entry);
|
|
}
|
|
parentEntry = entry;
|
|
}
|
|
|
|
return parentEntry;
|
|
};
|
|
|
|
for (const folderPath of folderPaths) ensureFolder(folderPath);
|
|
|
|
for (const connectionId of connectionIds) {
|
|
const connectionEntry: SidebarOrderEntry = { type: "connection", id: connectionId };
|
|
const folderEntry = ensureFolder(connectionFolderPaths.get(connectionId));
|
|
if (folderEntry) folderEntry.children!.push(connectionEntry);
|
|
else order.push(connectionEntry);
|
|
}
|
|
|
|
return groups.length ? { groups, order } : undefined;
|
|
}
|
|
|
|
function entryChildren(entry: Extract<SidebarOrderEntry, { type: "group" }>): SidebarOrderEntry[] {
|
|
return entry.children ?? entry.connectionIds?.map((id) => ({ type: "connection" as const, id })) ?? [];
|
|
}
|
|
|
|
function normalizeEntry(entry: SidebarOrderEntry, validIds: Set<string>, validGroups: Set<string>, seenConnections: Set<string>, seenGroups: Set<string>): SidebarOrderEntry | null {
|
|
if (entry.type === "connection") {
|
|
if (!validIds.has(entry.id) || seenConnections.has(entry.id)) return null;
|
|
seenConnections.add(entry.id);
|
|
return { type: "connection", id: entry.id };
|
|
}
|
|
|
|
if (!validGroups.has(entry.id) || seenGroups.has(entry.id)) return null;
|
|
seenGroups.add(entry.id);
|
|
|
|
const children = entryChildren(entry)
|
|
.map((child) => normalizeEntry(child, validIds, validGroups, seenConnections, seenGroups))
|
|
.filter(Boolean) as SidebarOrderEntry[];
|
|
return { type: "group", id: entry.id, children };
|
|
}
|
|
|
|
export function reconcileLayout(connectionIds: string[], layout: SidebarLayout | null): SidebarLayout {
|
|
if (!layout) {
|
|
return {
|
|
groups: [],
|
|
order: connectionIds.map((id) => ({ type: "connection" as const, id })),
|
|
};
|
|
}
|
|
|
|
const validIds = new Set(connectionIds);
|
|
const validGroups = new Set(layout.groups.map((group) => group.id));
|
|
const seenConnections = new Set<string>();
|
|
const seenGroups = new Set<string>();
|
|
const order = layout.order.map((entry) => normalizeEntry(entry, validIds, validGroups, seenConnections, seenGroups)).filter(Boolean) as SidebarOrderEntry[];
|
|
|
|
for (const id of connectionIds) {
|
|
if (!seenConnections.has(id)) {
|
|
order.push({ type: "connection", id });
|
|
}
|
|
}
|
|
|
|
const usedGroupIds = new Set<string>();
|
|
const collectGroups = (entries: SidebarOrderEntry[]) => {
|
|
for (const entry of entries) {
|
|
if (entry.type !== "group") continue;
|
|
usedGroupIds.add(entry.id);
|
|
collectGroups(entryChildren(entry));
|
|
}
|
|
};
|
|
collectGroups(order);
|
|
|
|
const groups = layout.groups.filter((group) => usedGroupIds.has(group.id));
|
|
return { groups, order };
|
|
}
|
|
|
|
export function remapSidebarLayoutConnectionIds(layout: SidebarLayout, connectionIdMap: Map<string, string>): SidebarLayout {
|
|
const remapEntries = (entries: SidebarOrderEntry[]): SidebarOrderEntry[] =>
|
|
entries.flatMap((entry): SidebarOrderEntry[] => {
|
|
if (entry.type === "connection") {
|
|
const id = connectionIdMap.get(entry.id);
|
|
return id ? [{ type: "connection", id }] : [];
|
|
}
|
|
|
|
const children = entryChildren(entry).flatMap((child): SidebarOrderEntry[] => remapEntries([child]));
|
|
return [{ type: "group", id: entry.id, children }];
|
|
});
|
|
|
|
return {
|
|
groups: layout.groups.map((group) => ({ ...group })),
|
|
order: remapEntries(layout.order),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merge an imported layout into the current one instead of replacing it.
|
|
* Folders are matched by name within the same level so repeated imports reuse
|
|
* them, and connections outside the imported set keep their current placement.
|
|
*/
|
|
export function mergeSidebarLayout(current: SidebarLayout, imported: SidebarLayout): SidebarLayout {
|
|
const importedGroups = new Map(imported.groups.map((group) => [group.id, group]));
|
|
const groups = current.groups.map((group) => ({ ...group }));
|
|
const groupNameById = new Map(groups.map((group) => [group.id, group.name]));
|
|
const order = cloneEntries(current.order);
|
|
|
|
// The imported layout owns the placement of the connections it lists, so drop
|
|
// their current entries first — otherwise they would end up listed twice.
|
|
const importedConnectionIds: string[] = [];
|
|
const collectConnections = (entries: SidebarOrderEntry[]) => {
|
|
for (const entry of entries) {
|
|
if (entry.type === "connection") importedConnectionIds.push(entry.id);
|
|
else collectConnections(entryChildren(entry));
|
|
}
|
|
};
|
|
collectConnections(imported.order);
|
|
for (const id of importedConnectionIds) removeEntry(order, id);
|
|
|
|
const merge = (target: SidebarOrderEntry[], source: SidebarOrderEntry[]) => {
|
|
for (const entry of source) {
|
|
if (entry.type === "connection") {
|
|
target.push({ type: "connection", id: entry.id });
|
|
continue;
|
|
}
|
|
|
|
const importedGroup = importedGroups.get(entry.id);
|
|
if (!importedGroup) continue;
|
|
|
|
let destination = target.find((candidate): candidate is Extract<SidebarOrderEntry, { type: "group" }> => candidate.type === "group" && groupNameById.get(candidate.id) === importedGroup.name);
|
|
if (!destination) {
|
|
// A fresh id keeps the merged tree free of collisions with the group ids
|
|
// the current layout already uses.
|
|
const id = uuid();
|
|
destination = { type: "group", id, children: [] };
|
|
groups.push({ ...importedGroup, id });
|
|
groupNameById.set(id, importedGroup.name);
|
|
target.push(destination);
|
|
}
|
|
destination.children ??= [];
|
|
merge(destination.children, entryChildren(entry));
|
|
}
|
|
};
|
|
merge(order, imported.order);
|
|
|
|
return { groups, order };
|
|
}
|
|
|
|
export function connectionSidebarSearchAliases(config: Pick<ConnectionConfig, "host" | "username">): string[] {
|
|
return [config.host, config.username].filter((value) => value.trim().length > 0);
|
|
}
|
|
|
|
function makeConnectionNode(config: ConnectionConfig, pinned: boolean): TreeNode {
|
|
return {
|
|
id: config.id,
|
|
label: config.name,
|
|
type: "connection",
|
|
searchAliases: connectionSidebarSearchAliases(config),
|
|
connectionId: config.id,
|
|
isExpanded: false,
|
|
children: [],
|
|
pinned,
|
|
// 连接备注复用 TreeNode.comment 通道,侧边栏按 sidebarObjectInfoMode 渲染。
|
|
comment: config.note || null,
|
|
};
|
|
}
|
|
|
|
export function buildTreeNodesFromLayout(layout: SidebarLayout, connections: ConnectionConfig[], pinnedIds: Set<string>): TreeNode[] {
|
|
const configMap = new Map(connections.map((connection) => [connection.id, connection]));
|
|
const groupMap = new Map(layout.groups.map((group) => [group.id, group]));
|
|
|
|
const build = (entries: SidebarOrderEntry[]): TreeNode[] => {
|
|
const nodes: TreeNode[] = [];
|
|
for (const entry of entries) {
|
|
if (entry.type === "connection") {
|
|
const config = configMap.get(entry.id);
|
|
if (config) nodes.push(makeConnectionNode(config, pinnedIds.has(entry.id)));
|
|
continue;
|
|
}
|
|
|
|
const group = groupMap.get(entry.id);
|
|
if (!group) continue;
|
|
nodes.push({
|
|
id: group.id,
|
|
label: group.name,
|
|
type: "connection-group",
|
|
pinned: pinnedIds.has(group.id),
|
|
isExpanded: !group.collapsed,
|
|
children: orderPinnedTreeNodes(build(entryChildren(entry))),
|
|
});
|
|
}
|
|
return nodes;
|
|
};
|
|
|
|
return orderPinnedTreeNodes(build(layout.order));
|
|
}
|
|
|
|
export function findConnectionLocation(layout: SidebarLayout, connectionId: string): { entries: SidebarOrderEntry[]; entryIndex: number; groupId?: string } | null {
|
|
const visit = (entries: SidebarOrderEntry[], groupId?: string): { entries: SidebarOrderEntry[]; entryIndex: number; groupId?: string } | null => {
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
if (entry.type === "connection" && entry.id === connectionId) return { entries, entryIndex: i, groupId };
|
|
if (entry.type === "group") {
|
|
const found = visit(entryChildren(entry), entry.id);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
return visit(layout.order);
|
|
}
|
|
|
|
/**
|
|
* Returns the display-name path for a connection's containing groups.
|
|
* A top-level connection returns an empty path; an absent connection returns null.
|
|
*/
|
|
export function findConnectionGroupPath(layout: SidebarLayout, connectionId: string): string[] | null {
|
|
const groupMap = new Map(layout.groups.map((group) => [group.id, group]));
|
|
|
|
const visit = (entries: SidebarOrderEntry[], path: string[]): string[] | null => {
|
|
for (const entry of entries) {
|
|
if (entry.type === "connection") {
|
|
if (entry.id === connectionId) return path;
|
|
continue;
|
|
}
|
|
|
|
const group = groupMap.get(entry.id);
|
|
if (!group) continue;
|
|
const found = visit(entryChildren(entry), [...path, group.name]);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return visit(layout.order, []);
|
|
}
|
|
|
|
/** Build all connection group paths in one traversal for list rendering. */
|
|
export function buildConnectionGroupPathMap(layout: SidebarLayout): Map<string, string[]> {
|
|
const groupMap = new Map(layout.groups.map((group) => [group.id, group]));
|
|
const paths = new Map<string, string[]>();
|
|
|
|
const visit = (entries: SidebarOrderEntry[], path: string[]) => {
|
|
for (const entry of entries) {
|
|
if (entry.type === "connection") {
|
|
paths.set(entry.id, path);
|
|
continue;
|
|
}
|
|
|
|
const group = groupMap.get(entry.id);
|
|
if (!group) continue;
|
|
visit(entryChildren(entry), [...path, group.name]);
|
|
}
|
|
};
|
|
|
|
visit(layout.order, []);
|
|
return paths;
|
|
}
|
|
|
|
function findGroupEntry(entries: SidebarOrderEntry[], groupId: string): Extract<SidebarOrderEntry, { type: "group" }> | null {
|
|
for (const entry of entries) {
|
|
if (entry.type !== "group") continue;
|
|
if (entry.id === groupId) return entry;
|
|
const found = findGroupEntry(entryChildren(entry), groupId);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function cloneEntries(entries: SidebarOrderEntry[]): SidebarOrderEntry[] {
|
|
return entries.map((entry) => (entry.type === "group" ? { type: "group", id: entry.id, children: cloneEntries(entryChildren(entry)) } : { ...entry }));
|
|
}
|
|
|
|
function removeEntry(entries: SidebarOrderEntry[], id: string): SidebarOrderEntry | null {
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
if ((entry.type === "connection" && entry.id === id) || (entry.type === "group" && entry.id === id)) {
|
|
entries.splice(i, 1);
|
|
return entry;
|
|
}
|
|
if (entry.type === "group") {
|
|
const removed = removeEntry(entry.children ?? [], id);
|
|
if (removed) return removed;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function removeConnectionFromEntries(entries: SidebarOrderEntry[], connectionId: string): SidebarOrderEntry[] {
|
|
const next = cloneEntries(entries);
|
|
removeEntry(next, connectionId);
|
|
return next;
|
|
}
|
|
|
|
function containsGroup(entry: SidebarOrderEntry, groupId: string): boolean {
|
|
if (entry.type !== "group") return false;
|
|
if (entry.id === groupId) return true;
|
|
return entryChildren(entry).some((child) => containsGroup(child, groupId));
|
|
}
|
|
|
|
function expandGroup(layout: SidebarLayout, groupId: string): SidebarLayout {
|
|
return {
|
|
...layout,
|
|
groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: false } : group)),
|
|
};
|
|
}
|
|
|
|
export function moveConnectionToGroup(layout: SidebarLayout, connectionId: string, targetGroupId: string | null): SidebarLayout {
|
|
const order = removeConnectionFromEntries(layout.order, connectionId);
|
|
const entry: SidebarOrderEntry = { type: "connection", id: connectionId };
|
|
|
|
if (targetGroupId) {
|
|
const group = findGroupEntry(order, targetGroupId);
|
|
if (group) {
|
|
group.children = [...(group.children ?? []), entry];
|
|
return { ...expandGroup(layout, targetGroupId), order };
|
|
}
|
|
}
|
|
|
|
order.push(entry);
|
|
return { ...layout, order };
|
|
}
|
|
|
|
export type DropPosition = "before" | "after" | "inside";
|
|
|
|
export function reorderEntry(layout: SidebarLayout, draggedId: string, targetId: string, position: DropPosition): SidebarLayout {
|
|
if (draggedId === targetId) return layout;
|
|
|
|
const order = cloneEntries(layout.order);
|
|
const dragged = removeEntry(order, draggedId);
|
|
if (!dragged) return layout;
|
|
|
|
if (dragged.type === "group" && containsGroup(dragged, targetId)) return layout;
|
|
|
|
if (position === "inside") {
|
|
const targetGroup = findGroupEntry(order, targetId);
|
|
if (targetGroup) {
|
|
targetGroup.children = [...(targetGroup.children ?? []), dragged];
|
|
return { ...layout, order };
|
|
}
|
|
}
|
|
|
|
const insertNear = (entries: SidebarOrderEntry[]): boolean => {
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
if ((entry.type === "connection" && entry.id === targetId) || (entry.type === "group" && entry.id === targetId)) {
|
|
entries.splice(position === "after" ? i + 1 : i, 0, dragged);
|
|
return true;
|
|
}
|
|
if (entry.type === "group" && insertNear(entry.children ?? [])) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (!insertNear(order)) order.push(dragged);
|
|
return { ...layout, order };
|
|
}
|
|
|
|
export function createGroup(layout: SidebarLayout, name: string, parentGroupId?: string | null): { layout: SidebarLayout; groupId: string } {
|
|
const groupId = uuid();
|
|
const group: ConnectionGroup = { id: groupId, name, collapsed: false };
|
|
const order = cloneEntries(layout.order);
|
|
const entry: SidebarOrderEntry = { type: "group", id: groupId, children: [] };
|
|
let parentFound = false;
|
|
|
|
if (parentGroupId) {
|
|
const parent = findGroupEntry(order, parentGroupId);
|
|
if (parent) {
|
|
parent.children = [...(parent.children ?? []), entry];
|
|
parentFound = true;
|
|
} else {
|
|
order.push(entry);
|
|
}
|
|
} else {
|
|
order.push(entry);
|
|
}
|
|
|
|
return {
|
|
groupId,
|
|
layout: {
|
|
groups: [...layout.groups, group].map((current) => (parentFound && current.id === parentGroupId ? { ...current, collapsed: false } : current)),
|
|
order,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function renameGroup(layout: SidebarLayout, groupId: string, name: string): SidebarLayout {
|
|
return {
|
|
...layout,
|
|
groups: layout.groups.map((group) => (group.id === groupId ? { ...group, name } : group)),
|
|
};
|
|
}
|
|
|
|
export function connectionIdsInGroups(layout: SidebarLayout, groupIds: Iterable<string>): string[] {
|
|
const targets = new Set(groupIds);
|
|
if (!targets.size) return [];
|
|
|
|
const connectionIds: string[] = [];
|
|
const seenConnectionIds = new Set<string>();
|
|
const collectConnections = (entries: SidebarOrderEntry[]) => {
|
|
for (const entry of entries) {
|
|
if (entry.type === "group") {
|
|
collectConnections(entryChildren(entry));
|
|
} else if (!seenConnectionIds.has(entry.id)) {
|
|
seenConnectionIds.add(entry.id);
|
|
connectionIds.push(entry.id);
|
|
}
|
|
}
|
|
};
|
|
const visit = (entries: SidebarOrderEntry[]) => {
|
|
for (const entry of entries) {
|
|
if (entry.type !== "group") continue;
|
|
if (targets.has(entry.id)) collectConnections(entryChildren(entry));
|
|
else visit(entryChildren(entry));
|
|
}
|
|
};
|
|
visit(layout.order);
|
|
return connectionIds;
|
|
}
|
|
|
|
export function deleteGroups(layout: SidebarLayout, groupIds: Iterable<string>): SidebarLayout {
|
|
const targets = new Set(groupIds);
|
|
if (!targets.size) return layout;
|
|
|
|
const removedGroupIds = new Set<string>();
|
|
const flattenDeletedGroup = (entry: Extract<SidebarOrderEntry, { type: "group" }>): SidebarOrderEntry[] => {
|
|
removedGroupIds.add(entry.id);
|
|
return entryChildren(entry).flatMap((child): SidebarOrderEntry[] => {
|
|
if (child.type === "connection") return [{ ...child }];
|
|
return flattenDeletedGroup(child);
|
|
});
|
|
};
|
|
const removeGroups = (entries: SidebarOrderEntry[]): SidebarOrderEntry[] =>
|
|
entries.flatMap((entry): SidebarOrderEntry[] => {
|
|
if (entry.type === "connection") return [{ ...entry }];
|
|
if (targets.has(entry.id)) return flattenDeletedGroup(entry);
|
|
const children = removeGroups(entryChildren(entry));
|
|
return [{ type: "group", id: entry.id, children }];
|
|
});
|
|
|
|
const order = removeGroups(layout.order);
|
|
if (!removedGroupIds.size) return layout;
|
|
return {
|
|
groups: layout.groups.filter((group) => !removedGroupIds.has(group.id)),
|
|
order,
|
|
};
|
|
}
|
|
|
|
export function deleteGroup(layout: SidebarLayout, groupId: string): SidebarLayout {
|
|
return deleteGroups(layout, [groupId]);
|
|
}
|
|
|
|
export function toggleGroupCollapsed(layout: SidebarLayout, groupId: string): SidebarLayout {
|
|
return {
|
|
...layout,
|
|
groups: layout.groups.map((group) => (group.id === groupId ? { ...group, collapsed: !group.collapsed } : group)),
|
|
};
|
|
}
|
|
|
|
export function collapseAllGroups(layout: SidebarLayout): SidebarLayout {
|
|
return {
|
|
...layout,
|
|
groups: layout.groups.map((group) => (group.collapsed ? group : { ...group, collapsed: true })),
|
|
};
|
|
}
|
|
|
|
export function removeConnectionFromSidebarLayout(layout: SidebarLayout, connectionId: string): SidebarLayout {
|
|
return { ...layout, order: removeConnectionFromEntries(layout.order, connectionId) };
|
|
}
|
|
|
|
export function appendConnectionToLayout(layout: SidebarLayout, connectionId: string, groupId?: string | null): SidebarLayout {
|
|
return moveConnectionToGroup(layout, connectionId, groupId ?? null);
|
|
}
|