From e4438780fe9faaa1913559b7d106e32ba835df40 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 28 May 2026 22:34:03 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sidebar):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=96=B0=E7=89=88=E5=B7=A6=E4=BE=A7=E5=88=86=E7=BB=84?= =?UTF-8?q?=E4=B8=8E=20Host=20=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sidebarRootOrder 持久化左侧根节点顺序 - 支持分组与未分组 Host 在新版左侧根层混排 - 统一 v2 rail 与树视图拖拽写回根层排序 - 拖拽期间抑制误选中与 Host 误切换 - 补充 Sidebar 与 store 拖拽排序回归测试 --- .../Sidebar.locate-toolbar.test.tsx | 316 +++++++- frontend/src/components/Sidebar.tsx | 766 +++++++++++++++--- frontend/src/store.test.ts | 141 ++++ frontend/src/store.ts | 467 ++++++++++- 4 files changed, 1529 insertions(+), 161 deletions(-) diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 06b19dd..bae29f7 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -9,16 +9,27 @@ import Sidebar, { filterV2ExplorerTreeByKind, getV2RailConnectionGroupBadgeText, hasSidebarLazyChildren, + normalizeSidebarTreeRelativeDropPosition, parseV2CommandSearchQuery, + resolveSidebarDropNodeFromDomEvent, + resolveSidebarTagDropInsertBefore, + resolveSidebarDropTargetMetricsFromDomEvent, + resolveSidebarDropInsertBefore, resolveSidebarNodeConnectionId, resolveV2ActiveConnectionId, isSidebarTablePinned, resolveSidebarTableNameForCopy, shouldClearSidebarActiveContextOnEmptySelect, + shouldSkipSidebarLoadOnExpandWhileDragging, + shouldSkipSidebarSelectWhileDragging, shouldLoadSidebarNodeOnExpand, sortSidebarTableEntries, } from './Sidebar'; -import { buildSidebarTablePinKey } from '../store'; +import { + buildSidebarRootConnectionToken, + buildSidebarRootTagToken, + buildSidebarTablePinKey, +} from '../store'; import { DEFAULT_SHORTCUT_OPTIONS, cloneShortcutOptions, @@ -58,6 +69,36 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('../store', () => ({ + buildSidebarRootConnectionToken: (connectionId: string) => `connection:${connectionId.trim()}`, + buildSidebarRootTagToken: (tagId: string) => `tag:${tagId.trim()}`, + resolveSidebarRootOrderTokens: ( + sidebarRootOrder: unknown, + connectionTags: Array<{ id: string; connectionIds: string[] }>, + connections: Array<{ id: string }>, + ) => { + const groupedConnectionIds = new Set(); + connectionTags.forEach((tag) => tag.connectionIds.forEach((id) => groupedConnectionIds.add(id))); + const fallback = [ + ...connectionTags.map((tag) => `tag:${tag.id}`), + ...connections + .filter((conn) => !groupedConnectionIds.has(conn.id)) + .map((conn) => `connection:${conn.id}`), + ]; + const valid = new Set(fallback); + const normalized = Array.isArray(sidebarRootOrder) + ? sidebarRootOrder + .map((item) => String(item ?? '').trim()) + .filter((item) => valid.has(item)) + : []; + const seen = new Set(); + const result: string[] = []; + [...normalized, ...fallback].forEach((token) => { + if (!token || seen.has(token)) return; + seen.add(token); + result.push(token); + }); + return result; + }, buildSidebarTablePinKey: ( connectionId: string, dbName: string, @@ -83,11 +124,14 @@ vi.mock('../store', () => ({ setActiveContext: mocks.noop, removeConnection: mocks.noop, connectionTags: mocks.state.connectionTags, + sidebarRootOrder: [], addConnectionTag: mocks.noop, updateConnectionTag: mocks.noop, removeConnectionTag: mocks.noop, moveConnectionToTag: mocks.noop, + reorderConnections: mocks.noop, reorderTags: mocks.noop, + reorderSidebarRoot: mocks.noop, closeTabsByConnection: mocks.noop, closeTabsByDatabase: mocks.noop, theme: 'light', @@ -180,7 +224,7 @@ describe('Sidebar locate toolbar', () => { const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); expect(source).toContain('if (hasSidebarLazyChildren(children)) return;'); - expect(source).toContain('if (info?.expanded && shouldLoadSidebarNodeOnExpand(info.node))'); + expect(source).toContain('if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info))'); expect(source).toContain('if (shouldLoadSidebarNodeOnExpand(node))'); }); @@ -237,6 +281,15 @@ describe('Sidebar locate toolbar', () => { })).toBe('dev240'); }); + it('keeps the v2 active host empty when nothing is selected', () => { + expect(resolveV2ActiveConnectionId({ + activeContextConnectionId: '', + activeTabConnectionId: '', + selectedKeys: [], + connectionIds: ['local', 'dev240', 'dev241'], + })).toBe(''); + }); + it('does not clear v2 active context when rc-tree emits an empty deselect', () => { expect(shouldClearSidebarActiveContextOnEmptySelect(true)).toBe(false); expect(shouldClearSidebarActiveContextOnEmptySelect(false)).toBe(true); @@ -249,20 +302,40 @@ describe('Sidebar locate toolbar', () => { { id: 'local', name: 'local', config: { type: 'mysql', host: 'localhost' } }, ] as any[]; - const groups = buildV2RailConnectionGroups(connections, [{ - id: 'prod', - name: '生产环境', - connectionIds: ['dev241', 'missing', 'dev240'], - }]); + const groups = buildV2RailConnectionGroups( + connections, + [{ + id: 'prod', + name: '生产环境', + connectionIds: ['dev241', 'missing', 'dev240'], + }], + [ + buildSidebarRootConnectionToken('local'), + buildSidebarRootTagToken('prod'), + ], + ); expect(groups.map((group) => ({ id: group.id, name: group.name, isUngrouped: group.isUngrouped, + rootToken: group.rootToken, connectionIds: group.connections.map((conn) => conn.id), }))).toEqual([ - { id: 'prod', name: '生产环境', isUngrouped: undefined, connectionIds: ['dev241', 'dev240'] }, - { id: '__gonavi-v2-ungrouped-connections__', name: '未分组', isUngrouped: true, connectionIds: ['local'] }, + { + id: 'local', + name: 'local', + isUngrouped: true, + rootToken: buildSidebarRootConnectionToken('local'), + connectionIds: ['local'], + }, + { + id: 'prod', + name: '生产环境', + isUngrouped: undefined, + rootToken: buildSidebarRootTagToken('prod'), + connectionIds: ['dev241', 'dev240'], + }, ]); expect(getV2RailConnectionGroupBadgeText('Production')).toBe('PR'); expect(getV2RailConnectionGroupBadgeText('生产环境')).toBe('生'); @@ -285,7 +358,7 @@ describe('Sidebar locate toolbar', () => { }); it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => { - const markup = renderToStaticMarkup(); + const markup = renderToStaticMarkup(); const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); expect(markup).toContain('gn-v2-sidebar-redesign'); @@ -315,16 +388,14 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('data-sidebar-batch-database-action="true"'); expect(markup).toContain('data-sidebar-open-external-sql-file-action="true"'); expect(markup).toContain('data-sidebar-locate-current-tab-action="true"'); + expect(markup).toContain('data-gonavi-create-connection-action="true"'); expect(markup).toContain('aria-label="AI 助手"'); expect(markup).toContain('data-gonavi-ai-entry-action="true"'); expect(markup).toContain('aria-label="工具"'); expect(markup).toContain('data-gonavi-open-tools-action="true"'); expect(markup).toContain('aria-label="设置"'); - expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags)'); - expect(source).toContain('data-v2-rail-connection-group="true"'); - expect(source).toContain('data-v2-rail-connection-group-header="true"'); + expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder)'); expect(source).toContain("kind: 'v2-connection-group'"); - expect(source).toContain('data-v2-rail-host-context-menu-trigger="true"'); expect(source).toContain('onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}'); expect(source).toContain("kind: 'v2-connection'"); expect(source).toContain("if (contextMenu.kind === 'v2-connection') return () => renderV2ConnectionContextMenu(contextMenu.node);"); @@ -365,6 +436,7 @@ describe('Sidebar locate toolbar', () => { it('scales the v2 rail and footer tools from global appearance tokens', () => { const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); + expect(css).toMatch(/\.gn-v2-rail-action-group,\s*body\[data-ui-version="v2"\] \.gn-v2-rail-system-actions \{[^}]*flex-direction: column;/s); expect(css).toMatch(/\.gn-v2-rail-action-group,\s*body\[data-ui-version="v2"\] \.gn-v2-rail-system-actions \{[^}]*flex-direction: column;/s); expect(css).toMatch(/\.gn-v2-rail-action-group \{[^}]*border-bottom: 0\.5px solid var\(--gn-br-1\);/s); expect(css).toMatch(/\.gn-v2-explorer-toolbar\s*\{[^}]*display:\s*none\s*!important/s); @@ -374,12 +446,11 @@ describe('Sidebar locate toolbar', () => { expect(css).toMatch(/\.gn-v2-tree-title\.is-mono \.gn-v2-tree-label \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 1px\), 17px\);/s); expect(css).toMatch(/\.gn-v2-tree-count \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 2px\), 16px\);/s); expect(css).toMatch(/\.gn-v2-connection-rail \{[^}]*width: calc\(54px \* var\(--gn-ui-scale, 1\)\);[^}]*flex: 0 0 calc\(54px \* var\(--gn-ui-scale, 1\)\);/s); - expect(css).toMatch(/\.gn-v2-rail-items \{[^}]*padding-top: calc\(4px \* var\(--gn-ui-scale, 1\)\);/s); - expect(css).toMatch(/\.gn-v2-rail-group-header \{[^}]*overflow: visible;/s); - expect(css).toMatch(/\.gn-v2-rail-group-chevron \{[^}]*font-size: 10px;/s); - expect(css).toMatch(/\.gn-v2-rail-group-count \{[^}]*top: -1px;[^}]*right: -1px;[^}]*min-width: 16px;[^}]*height: 16px;[^}]*font-size: 9px;/s); - expect(css).toMatch(/\.gn-v2-rail-item,[^}]*\.gn-v2-rail-tool \{[^}]*width: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*height: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*font-size: var\(--gn-font-size-sm, 12px\);/s); + expect(css).toMatch(/\.gn-v2-rail-item,[^}]*\.gn-v2-rail-tool \{[^}]*width: calc\(64px \* var\(--gn-ui-scale, 1\)\);[^}]*height: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*font-size: var\(--gn-font-size-sm, 12px\);/s); expect(css).toMatch(/\.gn-v2-rail-tool \{[^}]*height: calc\(32px \* var\(--gn-ui-scale, 1\)\);/s); + expect(css).toMatch(/\.gn-v2-rail-tool \{[^}]*width: calc\(34px \* var\(--gn-ui-scale, 1\)\);/s); + expect(css).toMatch(/\.gn-v2-active-connection-trigger \{[^}]*height: 34px;[^}]*border: 0;[^}]*background: transparent;/s); + expect(css).not.toContain('.gn-v2-active-connection-trigger:hover'); }); it('keeps v2 tree status dots circular while truncating only the label text', () => { @@ -387,12 +458,16 @@ describe('Sidebar locate toolbar', () => { const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); expect(source).toContain('gn-v2-tree-status is-${status}'); + expect(source).toContain('data-sidebar-tree-folder-icon="true"'); + expect(css).toMatch(/\.gn-v2-tree-title\.is-connection \{[^}]*align-items:\s*center;/s); expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title \{[^}]*overflow: visible;/s); expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title > \.gn-v2-tree-title \{[^}]*overflow: visible;/s); expect(css).toMatch(/\.gn-v2-tree-status \{[^}]*width: 14px;[^}]*height: 14px;[^}]*flex: 0 0 14px;[^}]*overflow: visible;/s); expect(css).toMatch(/\.gn-v2-tree-status::before \{[^}]*width: 7px;[^}]*height: 7px;[^}]*border-radius: 50%;/s); expect(css).toMatch(/\.gn-v2-tree-status\.is-success::before \{[^}]*background: #22c55e;[^}]*box-shadow: 0 0 0 4px rgba\(34, 197, 94, 0\.18\);/s); expect(css).toMatch(/\.gn-v2-tree-label \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;/s); + expect(css).toMatch(/\.gn-v2-tree-folder-icon \{[^}]*width: 22px;[^}]*height: 22px;[^}]*flex: 0 0 22px;/s); + expect(css).not.toContain('.gn-v2-tree-connection-meta'); }); it('does not repeat the active connection as an object-tree root in v2', () => { @@ -405,7 +480,7 @@ describe('Sidebar locate toolbar', () => { port: 3306, }, }]; - mocks.state.activeContext = { connectionId: 'conn-local', dbName: '' }; + mocks.state.activeContext = { connectionId: 'conn-local', dbName: 'app_db' }; mocks.state.activeTabId = ''; mocks.state.tabs = []; mocks.state.appearance = { @@ -420,11 +495,39 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('gn-v2-connection-rail'); expect(markup).toContain('gn-v2-active-connection-copy'); expect(markup).toContain('本地'); - expect(markup).toContain('localhost'); + expect(markup).toContain('app_db'); + expect(markup).not.toContain('localhost'); expect(markup).not.toContain('gn-v2-db-icon-label'); }); - it('renders existing connection tags as collapsible groups in the v2 rail', () => { + it('shows an empty v2 active host header when no host is selected', () => { + mocks.state.connections = [{ + id: 'conn-local', + name: '本地', + config: { + type: 'mysql', + host: 'localhost', + port: 3306, + }, + }]; + mocks.state.activeContext = null; + mocks.state.activeTabId = ''; + mocks.state.tabs = []; + mocks.state.appearance = { + enabled: true, + opacity: 1, + blur: 0, + uiVersion: 'v2', + }; + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('未选择 Host'); + expect(markup).toContain('未选择数据库'); + expect(markup).not.toContain('本地'); + }); + + it('keeps all filter backed by the full tree so hosts remain visible in v2', () => { mocks.state.connections = [ { id: 'dev240', @@ -461,15 +564,168 @@ describe('Sidebar locate toolbar', () => { }; const markup = renderToStaticMarkup(); + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); - expect(markup).toContain('data-v2-rail-connection-group="true"'); - expect(markup).toContain('data-v2-rail-connection-group-header="true"'); - expect(markup).toContain('title="生产环境 · 1 个连接"'); - expect(markup).toContain('title="未分组 · 1 个连接"'); - expect(markup).toContain('aria-label="折叠连接分组 生产环境"'); - expect(markup).toContain('aria-label="切换到连接 dev240"'); - expect(markup).toContain('aria-label="切换到连接 本地"'); - expect(markup).toContain('data-v2-rail-host-context-menu-trigger="true"'); + expect(source).toContain("if (v2ExplorerFilter === 'all') {"); + expect(source).toContain('gn-v2-tree-connection-copy'); + expect(source).not.toContain('gn-v2-tree-connection-meta'); + }); + + it('reorders dragged connections instead of only moving them between groups', () => { + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('const reorderConnections = useStore(state => state.reorderConnections);'); + expect(source).toContain('reorderConnections('); + expect(source).toContain('const insertBefore = resolveSidebarDropInsertBefore('); + expect(source).toContain('const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event);'); + expect(source).toContain('const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event);'); + expect(source).toContain("findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key)"); + expect(source).toContain("const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;"); + expect(source).toContain('insertBefore,'); + }); + + it('reorders dragged tags relative to grouped connections instead of always appending them', () => { + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + + expect(source).toContain("connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || ''"); + expect(source).toContain('const dropTagId = dropNode.type === \'tag\''); + expect(source).toContain('if (dropTagId) {'); + }); + + it('wires v2 rail root dragging through the shared sidebar root order action', () => { + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot);'); + expect(source).toContain('const [draggingV2RailRootToken, setDraggingV2RailRootToken] = useState(\'\');'); + expect(source).toContain('const treeDragSelectSuppressUntilRef = useRef(0);'); + expect(source).toContain('const treeDragSelectionSnapshotRef = useRef<'); + expect(source).toContain('snapshotTreeSelectionBeforeDrag();'); + expect(source).toContain('restoreTreeSelectionAfterDrag();'); + expect(source).toContain('if (Date.now() < treeDragSelectSuppressUntilRef.current) {'); + expect(source).toContain('handleV2RailRootDrop('); + expect(source).toContain('draggable'); + expect(source).toContain('setDraggingV2RailRootToken(rootToken);'); + expect(source).toContain('reorderSidebarRoot(sourceToken, targetToken, insertBefore);'); + }); + + it('normalizes rc-tree absolute drop positions back to relative positions', () => { + expect(normalizeSidebarTreeRelativeDropPosition(4, '0-0-4')).toBe(0); + expect(normalizeSidebarTreeRelativeDropPosition(3, '0-0-4')).toBe(-1); + expect(normalizeSidebarTreeRelativeDropPosition(5, '0-0-4')).toBe(1); + }); + + it('resolves insert-before from either relative drop position or pointer position', () => { + expect(resolveSidebarDropInsertBefore(-1, null)).toBe(true); + expect(resolveSidebarDropInsertBefore(1, null)).toBe(false); + expect(resolveSidebarDropInsertBefore(0, { + clientY: 102, + top: 100, + height: 20, + })).toBe(true); + expect(resolveSidebarDropInsertBefore(0, { + clientY: 118, + top: 100, + height: 20, + })).toBe(false); + }); + + it('resolves sidebar drop node metadata from DOM markers', () => { + vi.stubGlobal('document', { + elementFromPoint: () => null, + }); + const marker = { + getAttribute: (name: string) => { + if (name === 'data-sidebar-node-key') return 'conn-a'; + if (name === 'data-sidebar-node-type') return 'connection'; + return null; + }, + }; + const target = { + closest: (selector: string) => selector === '[data-sidebar-node-key]' ? marker : null, + }; + + expect(resolveSidebarDropNodeFromDomEvent({ + target: target as unknown as EventTarget, + })).toEqual({ + key: 'conn-a', + type: 'connection', + }); + vi.unstubAllGlobals(); + }); + + it('resolves sidebar drop target metrics from the full tree row instead of nested children', () => { + vi.stubGlobal('document', { + elementFromPoint: () => null, + }); + const treeNode = { + getBoundingClientRect: () => ({ + top: 128, + height: 26, + }), + }; + const target = { + closest: (selector: string) => { + if (selector === '.ant-tree-treenode') return treeNode; + return null; + }, + }; + + expect(resolveSidebarDropTargetMetricsFromDomEvent({ + target: target as unknown as EventTarget, + })).toEqual({ + top: 128, + height: 26, + }); + vi.unstubAllGlobals(); + }); + + it('treats centered tag drops as directional reordering instead of no-op', () => { + expect(resolveSidebarTagDropInsertBefore({ + currentTagOrder: ['tag-dev', 'tag-test', 'tag-prod'], + dragTagId: 'tag-prod', + dropTagId: 'tag-dev', + relativeDropPosition: 0, + fallbackInsertBefore: false, + metrics: { + clientY: 113, + top: 100, + height: 26, + }, + })).toBe(true); + + expect(resolveSidebarTagDropInsertBefore({ + currentTagOrder: ['tag-dev', 'tag-test', 'tag-prod'], + dragTagId: 'tag-dev', + dropTagId: 'tag-prod', + relativeDropPosition: 0, + fallbackInsertBefore: true, + metrics: { + clientY: 113, + top: 100, + height: 26, + }, + })).toBe(false); + }); + + it('skips sidebar select side effects while tree dragging is active', () => { + expect(shouldSkipSidebarSelectWhileDragging(true, { selected: true })).toBe(true); + expect(shouldSkipSidebarSelectWhileDragging(false, { selected: false })).toBe(true); + expect(shouldSkipSidebarSelectWhileDragging(false, { selected: true })).toBe(false); + }); + + it('skips sidebar lazy load on expand while tree dragging is active', () => { + expect(shouldSkipSidebarLoadOnExpandWhileDragging(true, { + expanded: true, + node: { type: 'connection', children: undefined, isLeaf: false } as any, + })).toBe(true); + expect(shouldSkipSidebarLoadOnExpandWhileDragging(false, { + expanded: false, + node: { type: 'connection', children: undefined, isLeaf: false } as any, + })).toBe(true); + expect(shouldSkipSidebarLoadOnExpandWhileDragging(false, { + expanded: true, + node: { type: 'connection', children: undefined, isLeaf: false } as any, + })).toBe(false); }); it('renders the v2 connection group context menu for rail group management', () => { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 61b351f..d321e70 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -44,7 +44,13 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, StarFilled, StarOutlined } from '@ant-design/icons'; -import { buildSidebarTablePinKey, useStore } from '../store'; +import { + buildSidebarRootConnectionToken, + buildSidebarRootTagToken, + buildSidebarTablePinKey, + resolveSidebarRootOrderTokens, + useStore, +} from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection, ConnectionTag, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; @@ -236,15 +242,17 @@ export interface V2RailConnectionGroup { name: string; connections: SavedConnection[]; isUngrouped?: boolean; + rootToken: string; } export const buildV2RailConnectionGroups = ( connections: SavedConnection[], connectionTags: ConnectionTag[], + sidebarRootOrder: string[] = [], ): V2RailConnectionGroup[] => { const connectionById = new Map(connections.map((conn) => [conn.id, conn])); const groupedConnectionIds = new Set(); - const groups: V2RailConnectionGroup[] = []; + const tagGroups = new Map(); connectionTags.forEach((tag) => { const tagConnections: SavedConnection[] = []; @@ -255,22 +263,62 @@ export const buildV2RailConnectionGroups = ( tagConnections.push(conn); }); if (tagConnections.length === 0) return; - groups.push({ + tagGroups.set(tag.id, { id: tag.id, name: tag.name || '未命名分组', connections: tagConnections, + rootToken: buildSidebarRootTagToken(tag.id), }); }); - const ungroupedConnections = connections.filter((conn) => !groupedConnectionIds.has(conn.id)); - if (ungroupedConnections.length > 0) { + const ungroupedConnectionMap = new Map( + connections + .filter((conn) => !groupedConnectionIds.has(conn.id)) + .map((conn) => [conn.id, conn]), + ); + const orderedRootTokens = resolveSidebarRootOrderTokens( + sidebarRootOrder, + connectionTags, + connections, + ); + const groups: V2RailConnectionGroup[] = []; + + orderedRootTokens.forEach((token) => { + if (token.startsWith('tag:')) { + const tagId = token.slice('tag:'.length); + const group = tagGroups.get(tagId); + if (!group) return; + groups.push(group); + tagGroups.delete(tagId); + return; + } + if (token.startsWith('connection:')) { + const connectionId = token.slice('connection:'.length); + const conn = ungroupedConnectionMap.get(connectionId); + if (!conn) return; + groups.push({ + id: connectionId, + name: conn.name, + connections: [conn], + isUngrouped: true, + rootToken: buildSidebarRootConnectionToken(connectionId), + }); + ungroupedConnectionMap.delete(connectionId); + } + }); + + tagGroups.forEach((group) => { + groups.push(group); + }); + ungroupedConnectionMap.forEach((conn) => { groups.push({ - id: V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, - name: groups.length > 0 ? '未分组' : '', - connections: ungroupedConnections, + id: conn.id, + name: conn.name, + connections: [conn], isUngrouped: true, + rootToken: buildSidebarRootConnectionToken(conn.id), }); - } + }); return groups; }; @@ -278,9 +326,29 @@ export const buildV2RailConnectionGroups = ( export const getV2RailConnectionGroupBadgeText = (name: unknown, fallback = '组'): string => { const trimmed = String(name ?? '').trim(); if (!trimmed) return fallback; - const ascii = trimmed.replace(/[^a-z0-9]/gi, ''); - if (ascii.length >= 2) return ascii.slice(0, 2).toUpperCase(); - return trimmed.slice(0, 1); + const cjkParts = trimmed.match(/[\u4e00-\u9fa5]/g); + if (cjkParts && cjkParts.length > 0) { + return cjkParts.slice(0, 1).join(''); + } + const latinTokens = trimmed.match(/[a-z0-9]+/gi) || []; + if (latinTokens.length >= 2) { + const firstToken = latinTokens[0] || ''; + const secondToken = latinTokens[1] || ''; + return `${firstToken[0] || ''}${secondToken[0] || ''}`.toUpperCase(); + } + if (latinTokens.length === 1) { + const token = latinTokens[0] || ''; + const alphaPrefix = token.match(/^[a-z]+/i)?.[0] || ''; + if (alphaPrefix) { + return alphaPrefix.slice(0, 2).toUpperCase(); + } + const trailingDigits = token.match(/(\d{2,})$/)?.[1]; + if (trailingDigits) { + return trailingDigits.slice(-2).toUpperCase(); + } + return token.slice(0, 2).toUpperCase(); + } + return trimmed.slice(0, 2); }; const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; label: string }> = [ @@ -441,6 +509,168 @@ export const resolveSidebarNodeConnectionId = ( return resolveSidebarConnectionIdFromKey(node?.key, connectionIds); }; +export const normalizeSidebarTreeRelativeDropPosition = ( + absoluteDropPosition: number, + nodePos: unknown, +): number => { + const segments = String(nodePos || '').split('-'); + const tailIndex = Number(segments[segments.length - 1] || 0); + return absoluteDropPosition - tailIndex; +}; + +export const resolveSidebarDropInsertBefore = ( + relativeDropPosition: number, + metrics?: { + clientY?: number; + top?: number; + height?: number; + } | null, +): boolean => { + if (relativeDropPosition < 0) return true; + if (relativeDropPosition > 0) return false; + const clientY = metrics?.clientY; + const top = metrics?.top; + const height = metrics?.height; + if ( + typeof clientY !== 'number' + || typeof top !== 'number' + || typeof height !== 'number' + || !Number.isFinite(clientY) + || !Number.isFinite(top) + || !Number.isFinite(height) + || height <= 0 + ) { + return false; + } + return clientY < (top + height / 2); +}; + +const resolveSidebarDropBaseElementFromDomEvent = ( + event: { + clientX?: number; + clientY?: number; + target?: EventTarget | null; + } | null | undefined, +): Element | null => { + if (typeof document === 'undefined') return null; + const fallbackTarget = event?.target && typeof (event.target as any).closest === 'function' + ? (event.target as unknown as Element) + : null; + const pointTarget = ( + typeof event?.clientX === 'number' + && typeof event?.clientY === 'number' + ) + ? document.elementFromPoint(event.clientX, event.clientY) + : null; + const baseElement = pointTarget || fallbackTarget; + if (!baseElement || typeof baseElement.closest !== 'function') return null; + return baseElement; +}; + +export const resolveSidebarDropNodeFromDomEvent = ( + event: { + clientX?: number; + clientY?: number; + target?: EventTarget | null; + } | null | undefined, +): { key: string; type: string } | null => { + const baseElement = resolveSidebarDropBaseElementFromDomEvent(event); + if (!baseElement) return null; + const marker = baseElement.closest('[data-sidebar-node-key]') as HTMLElement | null; + if (!marker) return null; + const key = String(marker.getAttribute('data-sidebar-node-key') || '').trim(); + const type = String(marker.getAttribute('data-sidebar-node-type') || '').trim(); + if (!key || !type) return null; + return { key, type }; +}; + +export const resolveSidebarDropTargetMetricsFromDomEvent = ( + event: { + clientX?: number; + clientY?: number; + target?: EventTarget | null; + } | null | undefined, +): { top: number; height: number } | null => { + const baseElement = resolveSidebarDropBaseElementFromDomEvent(event); + if (!baseElement) return null; + const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null; + if (!treeNode || typeof treeNode.getBoundingClientRect !== 'function') return null; + const rect = treeNode.getBoundingClientRect(); + if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) { + return null; + } + return { + top: rect.top, + height: rect.height, + }; +}; + +export const resolveSidebarTagDropInsertBefore = (options: { + currentTagOrder: string[]; + dragTagId: string; + dropTagId: string; + relativeDropPosition: number; + fallbackInsertBefore: boolean; + metrics?: { + clientY?: number; + top?: number; + height?: number; + } | null; +}): boolean => { + const { + currentTagOrder, + dragTagId, + dropTagId, + relativeDropPosition, + fallbackInsertBefore, + metrics, + } = options; + + if (relativeDropPosition !== 0) { + return fallbackInsertBefore; + } + + const clientY = metrics?.clientY; + const top = metrics?.top; + const height = metrics?.height; + if ( + typeof clientY !== 'number' + || typeof top !== 'number' + || typeof height !== 'number' + || !Number.isFinite(clientY) + || !Number.isFinite(top) + || !Number.isFinite(height) + || height <= 0 + ) { + return fallbackInsertBefore; + } + + const ratio = (clientY - top) / height; + if (ratio < 0.35) return true; + if (ratio > 0.65) return false; + + const dragIndex = currentTagOrder.indexOf(dragTagId); + const dropIndex = currentTagOrder.indexOf(dropTagId); + if (dragIndex === -1 || dropIndex === -1 || dragIndex === dropIndex) { + return fallbackInsertBefore; + } + return dragIndex > dropIndex; +}; + +export const shouldSkipSidebarSelectWhileDragging = ( + isTreeDragging: boolean, + info: { selected?: boolean } | null | undefined, +): boolean => isTreeDragging || !info?.selected; + +export const shouldSkipSidebarLoadOnExpandWhileDragging = ( + isTreeDragging: boolean, + info: { expanded?: boolean; node?: Pick | null } | null | undefined, +): boolean => { + if (isTreeDragging) return true; + if (!info?.expanded) return true; + return !shouldLoadSidebarNodeOnExpand(info.node); +}; + export const resolveV2ActiveConnectionId = ({ activeContextConnectionId, activeTabConnectionId, @@ -467,7 +697,6 @@ export const resolveV2ActiveConnectionId = ({ || selectedConnectionId || normalizeDirectId(fallbackConnectionId) || normalizeDirectId(activeTabConnectionId) - || connectionIds[0] || ''; }; @@ -602,11 +831,14 @@ const Sidebar: React.FC<{ const setActiveContext = useStore(state => state.setActiveContext); const removeConnection = useStore(state => state.removeConnection); const connectionTags = useStore(state => state.connectionTags); + const sidebarRootOrder = useStore(state => state.sidebarRootOrder); const addConnectionTag = useStore(state => state.addConnectionTag); const updateConnectionTag = useStore(state => state.updateConnectionTag); const removeConnectionTag = useStore(state => state.removeConnectionTag); const moveConnectionToTag = useStore(state => state.moveConnectionToTag); + const reorderConnections = useStore(state => state.reorderConnections); const reorderTags = useStore(state => state.reorderTags); + const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot); const closeTabsByConnection = useStore(state => state.closeTabsByConnection); const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase); const theme = useStore(state => state.theme); @@ -701,6 +933,16 @@ const Sidebar: React.FC<{ const selectedNodesRef = useRef([]); const loadingNodesRef = useRef>(new Set()); const clickTimerRef = useRef | null>(null); + const treeDragSelectSuppressUntilRef = useRef(0); + const treeDragSelectionSnapshotRef = useRef<{ + selectedKeys: React.Key[]; + selectedNodes: any[]; + activeContext: { connectionId: string; dbName: string } | null; + }>({ + selectedKeys: [], + selectedNodes: [], + activeContext: null, + }); const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record } | null>(null); const driverUpdateWarningKeysRef = useRef>(new Set()); const connectionReloadSignaturesRef = useRef>({}); @@ -708,8 +950,8 @@ const Sidebar: React.FC<{ const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]); const v2RailConnectionGroups = useMemo( - () => buildV2RailConnectionGroups(connections, connectionTags), - [connections, connectionTags], + () => buildV2RailConnectionGroups(connections, connectionTags, sidebarRootOrder), + [connections, connectionTags, sidebarRootOrder], ); const [collapsedV2RailGroupIds, setCollapsedV2RailGroupIds] = useState([]); const collapsedV2RailGroupIdSet = useMemo( @@ -717,6 +959,23 @@ const Sidebar: React.FC<{ [collapsedV2RailGroupIds], ); const hasV2RailConnectionGroups = v2RailConnectionGroups.some((group) => !group.isUngrouped); + const [draggingV2RailRootToken, setDraggingV2RailRootToken] = useState(''); + + const snapshotTreeSelectionBeforeDrag = useCallback(() => { + treeDragSelectionSnapshotRef.current = { + selectedKeys: [...selectedKeys], + selectedNodes: [...selectedNodesRef.current], + activeContext: activeContext ? { ...activeContext } : null, + }; + }, [activeContext, selectedKeys]); + + const restoreTreeSelectionAfterDrag = useCallback(() => { + const snapshot = treeDragSelectionSnapshotRef.current; + treeDragSelectSuppressUntilRef.current = Date.now() + 1000; + setSelectedKeys(snapshot.selectedKeys); + selectedNodesRef.current = snapshot.selectedNodes; + setActiveContext(snapshot.activeContext); + }, [setActiveContext]); const openV2CommandSearch = useCallback(() => { setIsV2CommandSearchOpen(true); @@ -782,6 +1041,7 @@ const Sidebar: React.FC<{ // Connection Status State: key -> 'success' | 'error' const [connectionStates, setConnectionStates] = useState>({}); + const [isTreeDragging, setIsTreeDragging] = useState(false); // Create Database Modal const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false); @@ -952,12 +1212,20 @@ const Sidebar: React.FC<{ }; const taggedConnIds = new Set(); - const tagNodes: TreeNode[] = connectionTags.map((tag) => { + const tagNodesById = new Map(); + connectionTags.forEach((tag) => { tag.connectionIds.forEach(id => taggedConnIds.add(id)); - return { + tagNodesById.set(tag.id, { title: tag.name, key: `tag-${tag.id}`, - icon: , + icon: ( + + + + ), type: 'tag', dataRef: tag, isLeaf: false, @@ -965,16 +1233,43 @@ const Sidebar: React.FC<{ .map(cid => connections.find(c => c.id === cid)) .filter(Boolean) .map(conn => buildConnectionNode(conn!)), - } as TreeNode; + } as TreeNode); }); - const ungroupedNodes: TreeNode[] = connections + const ungroupedNodesById = new Map(); + connections .filter(c => !taggedConnIds.has(c.id)) - .map(conn => buildConnectionNode(conn)); + .forEach((conn) => { + ungroupedNodesById.set(conn.id, buildConnectionNode(conn)); + }); - return [...tagNodes, ...ungroupedNodes]; + const orderedRootTokens = resolveSidebarRootOrderTokens( + sidebarRootOrder, + connectionTags, + connections, + ); + const orderedNodes: TreeNode[] = []; + orderedRootTokens.forEach((token) => { + if (token.startsWith('tag:')) { + const tagNode = tagNodesById.get(token.slice('tag:'.length)); + if (!tagNode) return; + orderedNodes.push(tagNode); + tagNodesById.delete(token.slice('tag:'.length)); + return; + } + if (token.startsWith('connection:')) { + const connectionNode = ungroupedNodesById.get(token.slice('connection:'.length)); + if (!connectionNode) return; + orderedNodes.push(connectionNode); + ungroupedNodesById.delete(token.slice('connection:'.length)); + } + }); + + orderedNodes.push(...Array.from(tagNodesById.values())); + orderedNodes.push(...Array.from(ungroupedNodesById.values())); + return orderedNodes; }); - }, [connections, connectionTags]); + }, [connections, connectionTags, sidebarRootOrder]); const handleDuplicateConnection = async (conn: SavedConnection) => { if (!conn?.id) return; @@ -2559,6 +2854,12 @@ const Sidebar: React.FC<{ if (isV2Ui && info?.node?.type === 'v2-table-section') { return; } + if (Date.now() < treeDragSelectSuppressUntilRef.current) { + return; + } + if (isTreeDragging) { + return; + } setSelectedKeys(keys); selectedNodesRef.current = info.selectedNodes || []; @@ -2568,7 +2869,7 @@ const Sidebar: React.FC<{ } return; } - if (!info.selected) return; + if (shouldSkipSidebarSelectWhileDragging(isTreeDragging, info)) return; const { type, dataRef, key, title } = info.node; const nodeConnectionId = resolveSidebarNodeConnectionId(info.node, connectionIds); @@ -2617,7 +2918,7 @@ const Sidebar: React.FC<{ const onExpand = (newExpandedKeys: React.Key[], info?: any) => { setExpandedKeys(newExpandedKeys); setAutoExpandParent(false); - if (info?.expanded && shouldLoadSidebarNodeOnExpand(info.node)) { + if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info)) { void onLoadData(info.node); } }; @@ -5146,8 +5447,14 @@ const Sidebar: React.FC<{ .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) .find(Boolean), }); - const activeConnection = connections.find((conn) => conn.id === activeConnectionId) || connections[0] || null; - const activeConnectionHostSummary = resolveConnectionHostSummary(activeConnection?.config) || '未配置地址'; + const activeConnection = connections.find((conn) => conn.id === activeConnectionId) || null; + const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || '未选择 Host'; + const activeDatabaseDisplayName = useMemo(() => { + if (activeContext && typeof activeContext === 'object' && 'dbName' in activeContext) { + return String(activeContext.dbName || '').trim(); + } + return String(activeTab?.dbName || '').trim(); + }, [activeContext, activeTab?.dbName]); const activeConnectionTreeData = useMemo(() => { if (!activeConnection) return displayTreeData; const activeConnectionNode = displayTreeData.find((node) => node.type === 'connection' && node.key === activeConnection.id); @@ -5170,10 +5477,12 @@ const Sidebar: React.FC<{ const filtered = filterTree(displayTreeData); return filtered; }, [activeConnection, displayTreeData]); - const v2VisibleTreeData = useMemo( - () => filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter), - [activeConnectionTreeData, v2ExplorerFilter], - ); + const v2VisibleTreeData = useMemo(() => { + if (v2ExplorerFilter === 'all') { + return displayTreeData; + } + return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter); + }, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]); const v2TreeMetrics = useMemo(() => { const databaseObjectCounts = new Map(); const objectGroupCounts = new Map(); @@ -5235,7 +5544,18 @@ const Sidebar: React.FC<{ )); }, []); - const getRailConnectionLabel = (conn: SavedConnection): string => { + const handleV2RailRootDrop = useCallback(( + sourceToken: string, + targetToken: string, + insertBefore: boolean, + ) => { + if (!sourceToken || !targetToken || sourceToken === targetToken) { + return; + } + reorderSidebarRoot(sourceToken, targetToken, insertBefore); + }, [reorderSidebarRoot]); + + const getRailConnectionTypeLabel = (conn: SavedConnection): string => { const iconType = resolveConnectionIconType(conn); if (iconType === 'mysql' || iconType === 'mariadb' || iconType === 'oceanbase') return 'MY'; if (iconType === 'postgres') return 'PG'; @@ -5249,6 +5569,67 @@ const Sidebar: React.FC<{ return iconType.slice(0, 2).toUpperCase() || 'DB'; }; + const getRailConnectionHostLabel = (conn: SavedConnection): string => { + const hostTokens = resolveConnectionHostTokens(conn.config); + const primaryHost = String(hostTokens[0] || '').trim().replace(/^\[|\]$/g, ''); + if (primaryHost) { + if (/^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0)$/i.test(primaryHost)) { + return 'LO'; + } + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(primaryHost)) { + const lastSegment = primaryHost.split('.').pop() || ''; + return lastSegment.slice(-3).toUpperCase() || 'IP'; + } + if (primaryHost.includes(':') && /^[a-f0-9:]+$/i.test(primaryHost)) { + const lastSegment = primaryHost.split(':').filter(Boolean).pop() || ''; + return lastSegment.slice(-3).toUpperCase() || 'IP'; + } + + const hostFragments = primaryHost + .split(/[^a-z0-9\u4e00-\u9fa5]+/i) + .map((entry) => entry.trim()) + .filter(Boolean); + if (hostFragments.length >= 2) { + return `${hostFragments[0][0] || ''}${hostFragments[1][0] || ''}`.toUpperCase(); + } + const hostToken = hostFragments[0] || primaryHost.split('.')[0] || ''; + if (hostToken) { + return hostToken.replace(/[^a-z0-9\u4e00-\u9fa5]/gi, '').slice(0, 3).toUpperCase() || 'DB'; + } + } + + return getRailConnectionTypeLabel(conn); + }; + + const getRailConnectionBadgeLabel = (conn: SavedConnection): string => { + const connectionName = String(conn.name || '').trim(); + const cjkParts = connectionName.match(/[\u4e00-\u9fa5]/g); + if (cjkParts && cjkParts.length > 0) { + return cjkParts.slice(0, 2).join(''); + } + + const latinTokens = connectionName.match(/[a-z0-9]+/gi) || []; + if (latinTokens.length >= 2) { + const firstToken = latinTokens[0] || ''; + const secondToken = latinTokens[1] || ''; + return `${firstToken[0] || ''}${secondToken[0] || ''}`.toUpperCase(); + } + if (latinTokens.length === 1) { + const token = latinTokens[0]; + const alphaPrefix = token.match(/^[a-z]+/i)?.[0] || ''; + if (alphaPrefix) { + return alphaPrefix.slice(0, 3).toUpperCase(); + } + const trailingDigits = token.match(/(\d{2,})$/)?.[1]; + if (trailingDigits) { + return trailingDigits.slice(-3).toUpperCase(); + } + return token.slice(0, 3).toUpperCase(); + } + + return getRailConnectionTypeLabel(conn); + }; + const openV2ConnectionContextMenu = ( event: React.MouseEvent, connOrNode: SavedConnection | TreeNode, @@ -5271,6 +5652,10 @@ const Sidebar: React.FC<{ }; const getV2TreeMetaText = (node: any): string => { + if (node.type === 'tag') { + const count = flattenConnectionNodes(node.children || []).length; + return count > 0 ? count.toLocaleString() : ''; + } if (node.type === 'database') { const count = v2TreeMetrics.databaseObjectCounts.get(node.key) || 0; return count > 0 ? count.toLocaleString() : ''; @@ -5601,6 +5986,22 @@ const Sidebar: React.FC<{ {node?.dataRef?.pinnedSidebarTable ? : } ) : null; + if (node.type === 'connection') { + return ( + + {statusBadge} + + {displayTitle} + + + ); + } return ( <> {statusBadge} {displayTitle} @@ -6767,37 +7170,98 @@ const Sidebar: React.FC<{ }; const handleDrop = (info: any) => { - const dropKey = info.node.key; - const dragKey = info.dragNode.key; - const dropPos = info.node.pos.split('-'); - const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]); + setIsTreeDragging(false); + const dropPosition = normalizeSidebarTreeRelativeDropPosition( + Number(info.dropPosition || 0), + info?.node?.pos, + ); + const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event); + const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event); + const insertBefore = resolveSidebarDropInsertBefore(dropPosition, dropTargetMetrics ? { + clientY: info?.event?.clientY, + top: dropTargetMetrics.top, + height: dropTargetMetrics.height, + } : null); const dragNode = info.dragNode; - const dropNode = info.node; + const dropNode = domDropNode && domDropNode.key === String(info?.node?.key || '') + ? info.node + : (domDropNode + ? findTreeNodeByKeyRef.current(treeDataRef.current, domDropNode.key) || info.node + : info.node); - // Tag to Tag reordering + const getDropRootToken = (node: any): string => { + if (!node) return ''; + if (node.type === 'tag') { + return buildSidebarRootTagToken(String(node?.dataRef?.id || '')); + } + if (node.type === 'connection') { + const groupedTagId = connectionTags.find((tag) => + tag.connectionIds.includes(String(node.key)), + )?.id || ''; + return groupedTagId + ? buildSidebarRootTagToken(groupedTagId) + : buildSidebarRootConnectionToken(String(node.key)); + } + return ''; + }; + + // Root tag or ungrouped connection reordering if (dragNode.type === 'tag') { - // You can only drop tags onto the root level (before/after other tags or connections at root) if (dropNode.type === 'tag' || dropNode.type === 'connection') { - // Get current order const currentTagOrder = connectionTags.map(t => t.id); const dragTagId = dragNode.dataRef.id; + const dropTagId = dropNode.type === 'tag' + ? dropNode.dataRef.id + : (connectionTags.find(t => t.connectionIds.includes(String(dropNode.key)))?.id || ''); + const dragRootToken = buildSidebarRootTagToken(String(dragTagId)); + const dropRootToken = getDropRootToken(dropNode); + + if (dropRootToken && dropRootToken !== dragRootToken) { + if (dropTagId) { + const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({ + currentTagOrder, + dragTagId, + dropTagId, + relativeDropPosition: dropPosition, + fallbackInsertBefore: insertBefore, + metrics: dropTargetMetrics ? { + clientY: info?.event?.clientY, + top: dropTargetMetrics.top, + height: dropTargetMetrics.height, + } : null, + }); + reorderSidebarRoot(dragRootToken, dropRootToken, resolvedInsertBefore); + } else { + reorderSidebarRoot(dragRootToken, dropRootToken, insertBefore); + } + return; + } - // Filter out the dragging tag const newOrder = currentTagOrder.filter(id => id !== dragTagId); - let insertIndex = newOrder.length; - if (dropNode.type === 'tag') { - const dropTagId = dropNode.dataRef.id; + if (dropTagId) { const dropIndex = newOrder.indexOf(dropTagId); + const resolvedInsertBefore = resolveSidebarTagDropInsertBefore({ + currentTagOrder, + dragTagId, + dropTagId, + relativeDropPosition: dropPosition, + fallbackInsertBefore: insertBefore, + metrics: dropTargetMetrics ? { + clientY: info?.event?.clientY, + top: dropTargetMetrics.top, + height: dropTargetMetrics.height, + } : null, + }); - if (dropPosition === -1) { + if (resolvedInsertBefore) { insertIndex = dropIndex; } else { insertIndex = dropIndex + 1; } } else { - // Dropped onto a root connection, usually meaning moving to the end of tags + // Dropped onto an ungrouped root connection, usually meaning moving to the end of tags // Since tags are always displayed before ungrouped connections, just put it at the end insertIndex = newOrder.length; } @@ -6808,27 +7272,38 @@ const Sidebar: React.FC<{ return; } + if (dragNode.type === 'connection') { + const dragTagId = connectionTags.find((tag) => + tag.connectionIds.includes(String(dragNode.key)), + )?.id || ''; + const dragIsUngroupedRoot = !dragTagId; + const dropRootToken = getDropRootToken(dropNode); + if (dragIsUngroupedRoot && dropNode.type === 'connection' && dropRootToken) { + reorderSidebarRoot( + buildSidebarRootConnectionToken(String(dragNode.key)), + dropRootToken, + insertBefore, + ); + return; + } + } + // Connection moving to tag (any drop position on a tag node counts as "into") if (dragNode.type === 'connection' && dropNode.type === 'tag') { moveConnectionToTag(dragNode.key, dropNode.dataRef.id); return; } - // Connection moving to another connection inside a tag + // Connection reordering against another connection if (dragNode.type === 'connection' && dropNode.type === 'connection') { - // Find if drop target is under a tag const targetTag = connectionTags.find(t => t.connectionIds.includes(dropNode.key)); - if (targetTag) { - moveConnectionToTag(dragNode.key, targetTag.id); - return; - } - - // Drop target is NOT under a tag (ungrouped) -> move OUT of tag - const sourceTag = connectionTags.find(t => t.connectionIds.includes(dragNode.key)); - if (sourceTag) { - moveConnectionToTag(dragNode.key, null); - return; - } + reorderConnections( + String(dragNode.key), + String(dropNode.key), + targetTag?.id || null, + insertBefore, + ); + return; } }; @@ -6891,14 +7366,51 @@ const Sidebar: React.FC<{ const renderV2RailConnectionButton = (conn: SavedConnection) => { const accent = resolveConnectionAccentColor(conn); const status = buildRailConnectionStatus(conn.id); - const label = getRailConnectionLabel(conn); + const badgeLabel = getRailConnectionBadgeLabel(conn); + const hostLabel = getRailConnectionHostLabel(conn); const title = `${conn.name} · ${resolveConnectionHostSummary(conn.config) || conn.config.type}`; + const rootToken = buildSidebarRootConnectionToken(conn.id); return ( ); @@ -6919,19 +7433,54 @@ const Sidebar: React.FC<{ const renderV2RailConnectionGroup = (group: V2RailConnectionGroup) => { const collapsed = collapsedV2RailGroupIdSet.has(group.id); const groupTitle = group.name || '连接'; - const groupLabel = getV2RailConnectionGroupBadgeText(group.name, group.isUngrouped ? '未' : '组'); + const rootToken = group.rootToken; return (
{ + snapshotTreeSelectionBeforeDrag(); + treeDragSelectSuppressUntilRef.current = Date.now() + 600; + setDraggingV2RailRootToken(rootToken); + setIsTreeDragging(true); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', rootToken); + }} + onDragEnd={() => { + restoreTreeSelectionAfterDrag(); + setDraggingV2RailRootToken(''); + setIsTreeDragging(false); + }} + onDragOver={(event) => { + if (!draggingV2RailRootToken || draggingV2RailRootToken === rootToken) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + }} + onDrop={(event) => { + if (!draggingV2RailRootToken || draggingV2RailRootToken === rootToken) { + return; + } + event.preventDefault(); + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + const insertBefore = event.clientY < rect.top + rect.height / 2; + handleV2RailRootDrop(draggingV2RailRootToken, rootToken, insertBefore); + restoreTreeSelectionAfterDrag(); + setDraggingV2RailRootToken(''); + setIsTreeDragging(false); + }} > {hasV2RailConnectionGroups && ( @@ -6970,23 +7519,9 @@ const Sidebar: React.FC<{ }; const renderV2ConnectionRail = () => ( -
-
- {v2RailConnectionGroups.map(renderV2RailConnectionGroup)} - - - -
+
-
+
- -
)}
@@ -7282,6 +7833,19 @@ const Sidebar: React.FC<{ icon: false, nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag' }} + onDragStart={() => { + snapshotTreeSelectionBeforeDrag(); + treeDragSelectSuppressUntilRef.current = Date.now() + 600; + setIsTreeDragging(true); + }} + onDragEnter={() => { + treeDragSelectSuppressUntilRef.current = Date.now() + 600; + setIsTreeDragging(true); + }} + onDragEnd={() => { + restoreTreeSelectionAfterDrag(); + setIsTreeDragging(false); + }} onDrop={handleDrop} loadData={onLoadData} treeData={isV2Ui ? v2VisibleTreeData : displayTreeData} diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 2c015b1..9dae189 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -497,6 +497,147 @@ describe('store appearance persistence', () => { ); }); + it('reorders connections inside tags and ungrouped roots independently', async () => { + const { useStore } = await importStore(); + + useStore.getState().replaceConnections([ + { + id: 'conn-a', + name: 'A', + config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' }, + }, + { + id: 'conn-b', + name: 'B', + config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' }, + }, + { + id: 'conn-c', + name: 'C', + config: { id: 'conn-c', type: 'mysql', host: 'c.local', port: 3306, user: 'root' }, + }, + { + id: 'conn-d', + name: 'D', + config: { id: 'conn-d', type: 'mysql', host: 'd.local', port: 3306, user: 'root' }, + }, + ]); + useStore.getState().addConnectionTag({ + id: 'tag-dev', + name: '开发', + connectionIds: ['conn-b', 'conn-d'], + }); + + useStore.getState().reorderConnections('conn-d', 'conn-b', 'tag-dev', true); + expect(useStore.getState().connectionTags[0]?.connectionIds).toEqual(['conn-d', 'conn-b']); + + useStore.getState().reorderConnections('conn-c', 'conn-a', null, true); + expect(useStore.getState().connections.map((conn) => conn.id)).toEqual([ + 'conn-c', + 'conn-a', + 'conn-b', + 'conn-d', + ]); + }); + + it('reorders sidebar root items across tags and ungrouped hosts', async () => { + const { + buildSidebarRootConnectionToken, + buildSidebarRootTagToken, + resolveSidebarRootOrderTokens, + useStore, + } = await importStore(); + + useStore.getState().replaceConnections([ + { + id: 'conn-a', + name: 'A', + config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' }, + }, + { + id: 'conn-b', + name: 'B', + config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' }, + }, + { + id: 'conn-c', + name: 'C', + config: { id: 'conn-c', type: 'mysql', host: 'c.local', port: 3306, user: 'root' }, + }, + ]); + useStore.getState().addConnectionTag({ + id: 'tag-dev', + name: '开发', + connectionIds: ['conn-b'], + }); + + const initialOrder = resolveSidebarRootOrderTokens( + useStore.getState().sidebarRootOrder, + useStore.getState().connectionTags, + useStore.getState().connections, + ); + expect(initialOrder).toEqual([ + buildSidebarRootTagToken('tag-dev'), + buildSidebarRootConnectionToken('conn-a'), + buildSidebarRootConnectionToken('conn-c'), + ]); + + useStore.getState().reorderSidebarRoot( + buildSidebarRootTagToken('tag-dev'), + buildSidebarRootConnectionToken('conn-c'), + false, + ); + + expect(resolveSidebarRootOrderTokens( + useStore.getState().sidebarRootOrder, + useStore.getState().connectionTags, + useStore.getState().connections, + )).toEqual([ + buildSidebarRootConnectionToken('conn-a'), + buildSidebarRootConnectionToken('conn-c'), + buildSidebarRootTagToken('tag-dev'), + ]); + }); + + it('restores ungrouped host root order after moving a host out of a tag', async () => { + const { + buildSidebarRootConnectionToken, + buildSidebarRootTagToken, + resolveSidebarRootOrderTokens, + useStore, + } = await importStore(); + + useStore.getState().replaceConnections([ + { + id: 'conn-a', + name: 'A', + config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' }, + }, + { + id: 'conn-b', + name: 'B', + config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' }, + }, + ]); + useStore.getState().addConnectionTag({ + id: 'tag-dev', + name: '开发', + connectionIds: ['conn-b'], + }); + + useStore.getState().moveConnectionToTag('conn-a', 'tag-dev'); + useStore.getState().moveConnectionToTag('conn-a', null); + + expect(resolveSidebarRootOrderTokens( + useStore.getState().sidebarRootOrder, + useStore.getState().connectionTags, + useStore.getState().connections, + )).toEqual([ + buildSidebarRootTagToken('tag-dev'), + buildSidebarRootConnectionToken('conn-a'), + ]); + }); + it('keeps legacy global proxy password during hydration until explicit cleanup', async () => { storage.setItem('lite-db-storage', JSON.stringify({ state: { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 6e550ee..7cbd9c5 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -851,6 +851,169 @@ const sanitizeConnectionTags = (value: unknown): ConnectionTag[] => { return result; }; +const SIDEBAR_ROOT_TAG_TOKEN_PREFIX = "tag:"; +const SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX = "connection:"; + +export const buildSidebarRootTagToken = (tagId: string): string => + `${SIDEBAR_ROOT_TAG_TOKEN_PREFIX}${toTrimmedString(tagId)}`; + +export const buildSidebarRootConnectionToken = ( + connectionId: string, +): string => `${SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX}${toTrimmedString(connectionId)}`; + +const isSidebarRootTagToken = (token: string): boolean => + token.startsWith(SIDEBAR_ROOT_TAG_TOKEN_PREFIX) && + token.length > SIDEBAR_ROOT_TAG_TOKEN_PREFIX.length; + +const isSidebarRootConnectionToken = (token: string): boolean => + token.startsWith(SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX) && + token.length > SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX.length; + +const sanitizeSidebarRootOrder = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const result: string[] = []; + value.forEach((entry) => { + const token = toTrimmedString(entry); + if (!token) return; + if (!isSidebarRootTagToken(token) && !isSidebarRootConnectionToken(token)) { + return; + } + if (seen.has(token)) return; + seen.add(token); + result.push(token); + }); + return result; +}; + +const buildDefaultSidebarRootOrderTokens = ( + connectionTags: ConnectionTag[], + connections: SavedConnection[], +): string[] => { + const groupedConnectionIds = new Set(); + connectionTags.forEach((tag) => { + tag.connectionIds.forEach((connectionId) => { + if (connectionId) groupedConnectionIds.add(connectionId); + }); + }); + + return [ + ...connectionTags.map((tag) => buildSidebarRootTagToken(tag.id)), + ...connections + .filter((connection) => !groupedConnectionIds.has(connection.id)) + .map((connection) => buildSidebarRootConnectionToken(connection.id)), + ]; +}; + +export const resolveSidebarRootOrderTokens = ( + sidebarRootOrder: unknown, + connectionTags: ConnectionTag[], + connections: SavedConnection[], +): string[] => { + const defaultOrder = buildDefaultSidebarRootOrderTokens( + connectionTags, + connections, + ); + if (defaultOrder.length === 0) { + return []; + } + + const validTokens = new Set(defaultOrder); + const seen = new Set(); + const result: string[] = []; + + sanitizeSidebarRootOrder(sidebarRootOrder).forEach((token) => { + if (!validTokens.has(token) || seen.has(token)) return; + seen.add(token); + result.push(token); + }); + + defaultOrder.forEach((token) => { + if (seen.has(token)) return; + seen.add(token); + result.push(token); + }); + + return result; +}; + +const insertSidebarRootTokenBeforeUngrouped = ( + sidebarRootOrder: string[], + token: string, +): string[] => { + if (!token || sidebarRootOrder.includes(token)) { + return [...sidebarRootOrder]; + } + const firstConnectionIndex = sidebarRootOrder.findIndex( + isSidebarRootConnectionToken, + ); + if (firstConnectionIndex === -1) { + return [...sidebarRootOrder, token]; + } + const nextOrder = [...sidebarRootOrder]; + nextOrder.splice(firstConnectionIndex, 0, token); + return nextOrder; +}; + +const insertSidebarRootTokenAfter = ( + sidebarRootOrder: string[], + token: string, + anchorToken: string, +): string[] => { + if (!token) return [...sidebarRootOrder]; + const nextOrder = sidebarRootOrder.filter((item) => item !== token); + const anchorIndex = nextOrder.indexOf(anchorToken); + if (anchorIndex === -1) { + nextOrder.push(token); + return nextOrder; + } + nextOrder.splice(anchorIndex + 1, 0, token); + return nextOrder; +}; + +const moveSidebarRootToken = ( + sidebarRootOrder: string[], + sourceToken: string, + targetToken: string, + insertBefore: boolean, +): string[] => { + if (!sourceToken || !targetToken || sourceToken === targetToken) { + return [...sidebarRootOrder]; + } + const filtered = sidebarRootOrder.filter((token) => token !== sourceToken); + const targetIndex = filtered.indexOf(targetToken); + const insertIndex = + targetIndex === -1 + ? filtered.length + : Math.max( + 0, + Math.min( + filtered.length, + insertBefore ? targetIndex : targetIndex + 1, + ), + ); + filtered.splice(insertIndex, 0, sourceToken); + return filtered; +}; + +const orderConnectionTagsBySidebarRootOrder = ( + connectionTags: ConnectionTag[], + sidebarRootOrder: string[], +): ConnectionTag[] => { + const tagMap = new Map(connectionTags.map((tag) => [tag.id, tag])); + const orderedTags: ConnectionTag[] = []; + sidebarRootOrder.forEach((token) => { + if (!isSidebarRootTagToken(token)) return; + const tagId = token.slice(SIDEBAR_ROOT_TAG_TOKEN_PREFIX.length); + const tag = tagMap.get(tagId); + if (!tag) return; + orderedTags.push(tag); + tagMap.delete(tagId); + }); + orderedTags.push(...Array.from(tagMap.values())); + return orderedTags; +}; + const isLegacyDefaultAppearance = ( appearance: Partial<{ opacity: number; blur: number }> | undefined, ): boolean => { @@ -887,6 +1050,7 @@ export interface QueryOptions { interface AppState { connections: SavedConnection[]; connectionTags: ConnectionTag[]; + sidebarRootOrder: string[]; tabs: TabData[]; activeTabId: string | null; activeContext: { connectionId: string; dbName: string } | null; @@ -955,7 +1119,18 @@ interface AppState { connectionId: string, targetTagId: string | null, ) => void; + reorderConnections: ( + connectionId: string, + targetConnectionId: string, + targetTagId: string | null, + insertBefore?: boolean, + ) => void; reorderTags: (tagIds: string[]) => void; + reorderSidebarRoot: ( + sourceToken: string, + targetToken: string, + insertBefore: boolean, + ) => void; addTab: (tab: TabData) => void; updateQueryTabDraft: ( @@ -1672,6 +1847,7 @@ export const useStore = create()( (set) => ({ connections: [], connectionTags: [], + sidebarRootOrder: [], tabs: [], activeTabId: null, activeContext: null, @@ -1739,31 +1915,87 @@ export const useStore = create()( }; }), removeConnection: (id) => - set((state) => ({ - connections: state.connections.filter((c) => c.id !== id), - connectionTags: state.connectionTags.map((tag) => ({ + set((state) => { + const nextConnections = state.connections.filter((c) => c.id !== id); + const nextTags = state.connectionTags.map((tag) => ({ ...tag, connectionIds: tag.connectionIds.filter((cid) => cid !== id), - })), - })), + })); + return { + connections: nextConnections, + connectionTags: nextTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + state.sidebarRootOrder.filter( + (token) => token !== buildSidebarRootConnectionToken(id), + ), + nextTags, + nextConnections, + ), + }; + }), replaceConnections: (connections) => - set((state) => ({ - connections: sanitizeConnections(connections), - shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions, - })), + set((state) => { + const nextConnections = sanitizeConnections(connections); + return { + connections: nextConnections, + sidebarRootOrder: resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + state.connectionTags, + nextConnections, + ), + shortcutOptions: + readPersistedShortcutOptions() ?? state.shortcutOptions, + }; + }), addConnectionTag: (tag) => - set((state) => ({ connectionTags: [...state.connectionTags, tag] })), + set((state) => { + const nextTags = [...state.connectionTags, tag]; + const nextRootOrder = insertSidebarRootTokenBeforeUngrouped( + resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + state.connectionTags, + state.connections, + ), + buildSidebarRootTagToken(tag.id), + ); + return { + connectionTags: nextTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + nextRootOrder, + nextTags, + state.connections, + ), + }; + }), updateConnectionTag: (tag) => - set((state) => ({ - connectionTags: state.connectionTags.map((t) => + set((state) => { + const nextTags = state.connectionTags.map((t) => t.id === tag.id ? tag : t, - ), - })), + ); + return { + connectionTags: nextTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + nextTags, + state.connections, + ), + }; + }), removeConnectionTag: (id) => - set((state) => ({ - connectionTags: state.connectionTags.filter((t) => t.id !== id), - })), + set((state) => { + const nextTags = state.connectionTags.filter((t) => t.id !== id); + return { + connectionTags: nextTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + state.sidebarRootOrder.filter( + (token) => token !== buildSidebarRootTagToken(id), + ), + nextTags, + state.connections, + ), + }; + }), moveConnectionToTag: (connectionId, targetTagId) => set((state) => { const newTags = state.connectionTags.map((tag) => { @@ -1776,22 +2008,186 @@ export const useStore = create()( } return { ...tag, connectionIds: filteredIds }; }); - return { connectionTags: newTags }; + const nextRootOrder = resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + newTags, + state.connections, + ); + const connectionToken = buildSidebarRootConnectionToken(connectionId); + if (targetTagId) { + return { + connectionTags: newTags, + sidebarRootOrder: nextRootOrder.filter( + (token) => token !== connectionToken, + ), + }; + } + + const sourceToken = buildSidebarRootTagToken( + state.connectionTags.find((tag) => + tag.connectionIds.includes(connectionId), + )?.id || "", + ); + const insertedRootOrder = sourceToken + ? insertSidebarRootTokenAfter(nextRootOrder, connectionToken, sourceToken) + : insertSidebarRootTokenBeforeUngrouped(nextRootOrder, connectionToken); + return { + connectionTags: newTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + insertedRootOrder, + newTags, + state.connections, + ), + }; + }), + reorderConnections: ( + connectionId, + targetConnectionId, + targetTagId, + insertBefore = false, + ) => + set((state) => { + if ( + !connectionId || + !targetConnectionId || + connectionId === targetConnectionId + ) { + return { + connections: state.connections, + connectionTags: state.connectionTags, + }; + } + + const normalizeInsertIndex = ( + length: number, + index: number, + ): number => Math.max(0, Math.min(length, index)); + + const nextTags = state.connectionTags.map((tag) => ({ + ...tag, + connectionIds: tag.connectionIds.filter((id) => id !== connectionId), + })); + + if (targetTagId) { + const updatedTags = nextTags.map((tag) => { + if (tag.id !== targetTagId) { + return tag; + } + const targetIndex = tag.connectionIds.indexOf(targetConnectionId); + if (targetIndex === -1) { + return { + ...tag, + connectionIds: [...tag.connectionIds, connectionId], + }; + } + const insertIndex = normalizeInsertIndex( + tag.connectionIds.length, + insertBefore ? targetIndex : targetIndex + 1, + ); + const nextIds = [...tag.connectionIds]; + nextIds.splice(insertIndex, 0, connectionId); + return { ...tag, connectionIds: nextIds }; + }); + return { + connections: state.connections, + connectionTags: updatedTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + updatedTags, + state.connections, + ), + }; + } + + const ungroupedIds = state.connections + .map((conn) => conn.id) + .filter((id) => id !== connectionId) + .filter((id) => !nextTags.some((tag) => tag.connectionIds.includes(id))); + const targetIndex = ungroupedIds.indexOf(targetConnectionId); + const insertIndex = + targetIndex === -1 + ? ungroupedIds.length + : normalizeInsertIndex( + ungroupedIds.length, + insertBefore ? targetIndex : targetIndex + 1, + ); + const nextUngroupedIds = [...ungroupedIds]; + nextUngroupedIds.splice(insertIndex, 0, connectionId); + const ungroupedOrderMap = new Map( + nextUngroupedIds.map((id, index) => [id, index]), + ); + const nextConnections = [...state.connections].sort((a, b) => { + const indexA = ungroupedOrderMap.get(a.id); + const indexB = ungroupedOrderMap.get(b.id); + if (typeof indexA === 'number' && typeof indexB === 'number') { + return indexA - indexB; + } + if (typeof indexA === 'number') { + return -1; + } + if (typeof indexB === 'number') { + return 1; + } + return 0; + }); + + return { + connections: nextConnections, + connectionTags: nextTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + nextTags, + nextConnections, + ), + }; }), reorderTags: (tagIds) => set((state) => { - const tagMap = new Map(state.connectionTags.map((t) => [t.id, t])); - const newTags: ConnectionTag[] = []; - tagIds.forEach((id) => { - const tag = tagMap.get(id); - if (tag) { - newTags.push(tag); - tagMap.delete(id); - } - }); - // 追加未指定的tag(如果有的话) - newTags.push(...Array.from(tagMap.values())); - return { connectionTags: newTags }; + const nextRootOrder = resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + state.connectionTags, + state.connections, + ); + const orderedRootOrder = [ + ...tagIds.map((id) => buildSidebarRootTagToken(id)), + ...nextRootOrder.filter((token) => !isSidebarRootTagToken(token)), + ]; + const newTags = orderConnectionTagsBySidebarRootOrder( + state.connectionTags, + orderedRootOrder, + ); + return { + connectionTags: newTags, + sidebarRootOrder: resolveSidebarRootOrderTokens( + orderedRootOrder, + newTags, + state.connections, + ), + }; + }), + reorderSidebarRoot: (sourceToken, targetToken, insertBefore) => + set((state) => { + const nextRootOrder = moveSidebarRootToken( + resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + state.connectionTags, + state.connections, + ), + sourceToken, + targetToken, + insertBefore, + ); + return { + sidebarRootOrder: resolveSidebarRootOrderTokens( + nextRootOrder, + state.connectionTags, + state.connections, + ), + connectionTags: orderConnectionTagsBySidebarRootOrder( + state.connectionTags, + nextRootOrder, + ), + }; }), addTab: (tab) => @@ -2504,6 +2900,11 @@ export const useStore = create()( state.connectionTags, ); } + nextState.sidebarRootOrder = resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + nextState.connectionTags, + nextState.connections, + ); nextState.savedQueries = sanitizeSavedQueries(state.savedQueries); nextState.externalSQLDirectories = sanitizeExternalSQLDirectories( state.externalSQLDirectories, @@ -2575,6 +2976,11 @@ export const useStore = create()( ...state, connections: sanitizeConnections(state.connections), connectionTags: sanitizeConnectionTags(state.connectionTags), + sidebarRootOrder: resolveSidebarRootOrderTokens( + state.sidebarRootOrder, + sanitizeConnectionTags(state.connectionTags), + sanitizeConnections(state.connections), + ), tabs: safeTabs, activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs), savedQueries: sanitizeSavedQueries(state.savedQueries), @@ -2621,6 +3027,7 @@ export const useStore = create()( tabs, activeTabId: sanitizeActiveTabId(state.activeTabId, tabs), connectionTags: state.connectionTags, + sidebarRootOrder: state.sidebarRootOrder, savedQueries: state.savedQueries, externalSQLDirectories: state.externalSQLDirectories, theme: state.theme,