From 255e484dcfa1662497f257a1657c5d4c9ed14217 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 31 May 2026 13:34:21 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E4=B8=8A=E4=B8=8B=E6=96=87=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=AF=B9=E8=B1=A1=E6=A0=91=E7=BB=9F=E8=AE=A1=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 切换和关闭标签时同步 activeContext,避免新建查询误用 host 或数据库 - 侧边栏表节点展示行数统计,数据库节点展示表数量 - 旧版 sidebar 工具栏改为稳定五列布局,v1 不再混入 v2 置顶分组 - 补充 sidebar 与 store 回归测试 --- .../Sidebar.locate-toolbar.test.tsx | 46 ++- frontend/src/components/Sidebar.tsx | 302 ++++++++++++++---- .../components/Sidebar.v2-metadata.test.tsx | 13 + frontend/src/store.test.ts | 72 +++++ frontend/src/store.ts | 124 ++++++- 5 files changed, 473 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/Sidebar.v2-metadata.test.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index bae29f7..249d60e 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -4,6 +4,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import Sidebar, { + buildSidebarTableChildrenForUi, buildV2SidebarTableSectionedChildren, buildV2RailConnectionGroups, filterV2ExplorerTreeByKind, @@ -357,6 +358,20 @@ describe('Sidebar locate toolbar', () => { expect(locateActionIndex).toBeGreaterThan(externalSqlActionIndex); }); + it('keeps the legacy sidebar toolbar on a stable five-column grid layout', () => { + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-sidebar-legacy-toolbar="true"'); + expect(markup).toContain('data-sidebar-legacy-toolbar-item="true"'); + expect(source).toContain("const legacyToolbarStyle: React.CSSProperties = {"); + expect(source).toContain("gridTemplateColumns: 'repeat(5, minmax(0, 1fr))'"); + expect(source).toContain("justifyItems: 'center'"); + expect(source).toContain("const legacyToolbarItemStyle: React.CSSProperties = {"); + expect(source).toContain("const legacyToolbarDisabledWrapStyle: React.CSSProperties = {"); + expect(source).not.toContain("justifyContent: 'space-between', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)' }}>"); + }); + it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => { const markup = renderToStaticMarkup(); const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); @@ -382,7 +397,8 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('gn-v2-sidebar-log-footer'); expect(markup).toContain('SQL 执行日志'); expect(markup).toContain('2,341'); - expect(markup).toContain('gn-v2-rail-action-group'); + expect(markup).not.toContain('gn-v2-rail-action-group'); + expect(source).toContain('className="gn-v2-rail-primary-actions"'); expect(markup).toContain('data-sidebar-create-group-action="true"'); expect(markup).toContain('data-sidebar-batch-table-action="true"'); expect(markup).toContain('data-sidebar-batch-database-action="true"'); @@ -443,12 +459,12 @@ describe('Sidebar locate toolbar', () => { expect(css).toMatch(/\.ant-tree \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s); expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s); expect(css).toMatch(/\.gn-v2-tree-title \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s); - 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-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-tree-title\.is-mono \.gn-v2-tree-label \{[^}]*font-size: inherit;[^}]*font-weight: 400 !important;/s); + expect(css).toMatch(/\.gn-v2-tree-count \{[^}]*font-size: clamp\(10px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 1px\), 16px\);/s); + expect(css).toMatch(/\.gn-v2-connection-rail \{[^}]*width: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*flex: 0 0 calc\(38px \* var\(--gn-ui-scale, 1\)\);/s); + expect(css).toMatch(/\.gn-v2-rail-item,\s*body\[data-ui-version="v2"\] \.gn-v2-rail-tool \{[^}]*width: calc\(36px \* 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-rail-tool \{[^}]*width: calc\(24px \* 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'); }); @@ -921,6 +937,24 @@ describe('Sidebar locate toolbar', () => { }); }); + it('keeps legacy sidebar table groups flat and ignores v2 pin sections', () => { + const tableNodes = [ + { title: 'orders', key: 'orders', type: 'table' as const, dataRef: { pinnedSidebarTable: true } }, + { title: 'users', key: 'users', type: 'table' as const, dataRef: { pinnedSidebarTable: false } }, + ]; + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + + expect(buildSidebarTableChildrenForUi('conn-main-tables', tableNodes, false)).toBe(tableNodes); + expect(buildSidebarTableChildrenForUi('conn-main-tables', tableNodes, true).map((node) => node.title)).toEqual([ + '置顶', + 'orders', + '全部', + 'users', + ]); + expect(source).toContain('pinnedSidebarTables: isV2Ui ? currentPinnedSidebarTables : []'); + expect(source).toContain('buildSidebarTableChildrenForUi(groupNodeKey, children, isV2Ui)'); + }); + it('keeps v2 table sections out of regular table lists when nothing is pinned', () => { const tableNodes = [ { title: 'users', key: 'users', type: 'table' as const, dataRef: { pinnedSidebarTable: false } }, diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8da84bf..3f04e69 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -151,6 +151,7 @@ type SidebarTableEntryForSort = { tableName: string; schemaName?: string; displayName: string; + rowCount?: number; }; export const isSidebarTablePinned = ( @@ -227,6 +228,22 @@ export const buildV2SidebarTableSectionedChildren = ( ]; }; +export const buildSidebarTableChildrenForUi = ( + parentKey: string, + tableNodes: TreeNode[], + isV2Ui: boolean, +): TreeNode[] => { + if (!isV2Ui) return tableNodes; + return buildV2SidebarTableSectionedChildren(parentKey, tableNodes); +}; + +export const formatSidebarRowCount = (count: number): string => { + if (!Number.isFinite(count) || count < 0) return ''; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(Math.round(count)); +}; + type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; type BatchObjectType = 'table' | 'view'; type BatchObjectFilterType = 'all' | BatchObjectType; @@ -1543,6 +1560,80 @@ const Sidebar: React.FC<{ return ''; }; + const parseMetadataRowCount = (row: Record): number | undefined => { + const rawValue = getCaseInsensitiveRawValue(row, ['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']); + if (rawValue === undefined || rawValue === null || rawValue === '') { + return undefined; + } + const parsed = Number(String(rawValue).replace(/,/g, '')); + if (!Number.isFinite(parsed) || parsed < 0) { + return undefined; + } + return Math.round(parsed); + }; + + const buildSidebarTableStatusSQL = (conn: SavedConnection, dbName: string): string => { + const dialect = getMetadataDialect(conn); + const safeDbName = escapeSQLLiteral(dbName); + switch (dialect) { + case 'mysql': + case 'starrocks': + return [ + 'SELECT TABLE_NAME AS table_name, TABLE_ROWS AS table_rows', + 'FROM information_schema.tables', + `WHERE table_schema = '${safeDbName}'`, + "AND table_type = 'BASE TABLE'", + 'ORDER BY table_name', + ].join('\n'); + case 'postgres': + case 'kingbase': + case 'vastbase': + case 'highgo': + case 'opengauss': + return [ + "SELECT n.nspname || '.' || c.relname AS table_name, c.reltuples::bigint AS table_rows", + 'FROM pg_class c', + 'JOIN pg_namespace n ON n.oid = c.relnamespace', + "WHERE c.relkind = 'r'", + "AND n.nspname NOT IN ('information_schema', 'pg_catalog')", + "AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'", + 'ORDER BY n.nspname, c.relname', + ].join('\n'); + case 'sqlserver': { + const safeDb = quoteSqlServerIdentifier(dbName); + return [ + 'SELECT s.name + \'.\' + t.name AS table_name, SUM(p.rows) AS table_rows', + `FROM ${safeDb}.sys.tables t`, + `JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id`, + `LEFT JOIN ${safeDb}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)`, + 'WHERE t.type = \'U\'', + 'GROUP BY s.name, t.name', + 'ORDER BY s.name, t.name', + ].join('\n'); + } + case 'clickhouse': + return [ + 'SELECT name AS table_name, total_rows AS table_rows', + 'FROM system.tables', + `WHERE database = '${safeDbName}'`, + "AND engine NOT IN ('View', 'MaterializedView')", + 'ORDER BY name', + ].join('\n'); + case 'oracle': + case 'dm': { + const owner = escapeSQLLiteral(dbName).toUpperCase(); + return [ + 'SELECT table_name, num_rows AS table_rows', + 'FROM all_tables', + `WHERE owner = '${owner}'`, + 'ORDER BY table_name', + ].join('\n'); + } + default: + return ''; + } + }; + const buildQualifiedName = (schemaName: string, objectName: string): string => { const schema = String(schemaName || '').trim(); const name = String(objectName || '').trim(); @@ -2310,6 +2401,24 @@ const Sidebar: React.FC<{ setConnectionStates(prev => ({ ...prev, [key as string]: 'success' })); const tableRows: any[] = Array.isArray(res.data) ? res.data : []; + const tableStatusSql = buildSidebarTableStatusSQL(conn as SavedConnection, conn.dbName); + const tableStatsResult = tableStatusSql + ? await DBQuery(buildRpcConnectionConfig(config) as any, conn.dbName, tableStatusSql).catch(() => ({ success: false, data: [] as any[] })) + : { success: false, data: [] as any[] }; + const tableRowCountMap = new Map(); + if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) { + tableStatsResult.data.forEach((row: Record) => { + const rawTableName = String( + getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'Name', 'name']) + || getMySQLShowTablesName(row) + || '' + ).trim(); + if (!rawTableName) return; + const rowCount = parseMetadataRowCount(row); + if (rowCount === undefined) return; + tableRowCountMap.set(rawTableName.toLowerCase(), rowCount); + }); + } const tableEntries = tableRows.map((row: any) => { const tableName = Object.values(row)[0] as string; const parsed = splitQualifiedName(tableName); @@ -2317,6 +2426,7 @@ const Sidebar: React.FC<{ tableName, schemaName: parsed.schemaName, displayName: getSidebarTableDisplayName(conn, tableName), + rowCount: tableRowCountMap.get(String(tableName || '').trim().toLowerCase()), }; }); @@ -2454,7 +2564,7 @@ const Sidebar: React.FC<{ dbName: conn.dbName, sortBy, tableAccessCount: currentTableAccessCount, - pinnedSidebarTables: currentPinnedSidebarTables, + pinnedSidebarTables: isV2Ui ? currentPinnedSidebarTables : [], }); // Sort views by name (case-insensitive) @@ -2470,14 +2580,26 @@ const Sidebar: React.FC<{ eventEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); - const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string }): TreeNode => { - const isPinned = isSidebarTablePinned(currentPinnedSidebarTables, conn.id, conn.dbName, entry.tableName, entry.schemaName); + const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string; rowCount?: number }): TreeNode => { + const isPinned = isV2Ui && isSidebarTablePinned( + currentPinnedSidebarTables, + conn.id, + conn.dbName, + entry.tableName, + entry.schemaName, + ); return { title: entry.displayName, key: `${conn.id}-${conn.dbName}-${entry.tableName}`, icon: , type: 'table', - dataRef: { ...conn, tableName: entry.tableName, schemaName: entry.schemaName, pinnedSidebarTable: isPinned }, + dataRef: { + ...conn, + tableName: entry.tableName, + schemaName: entry.schemaName, + rowCount: entry.rowCount, + ...(isPinned ? { pinnedSidebarTable: true } : {}), + }, isLeaf: false, }; }; @@ -2537,7 +2659,7 @@ const Sidebar: React.FC<{ ): TreeNode => { const groupNodeKey = `${parentKey}-${groupKey}`; const groupedChildren = groupKey === 'tables' - ? buildV2SidebarTableSectionedChildren(groupNodeKey, children) + ? buildSidebarTableChildrenForUi(groupNodeKey, children, isV2Ui) : children; return { title: groupTitle, @@ -5483,7 +5605,7 @@ const Sidebar: React.FC<{ return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter); }, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]); const v2TreeMetrics = useMemo(() => { - const databaseObjectCounts = new Map(); + const databaseTableCounts = new Map(); const objectGroupCounts = new Map(); let activeObjectCount = 0; @@ -5491,7 +5613,21 @@ const Sidebar: React.FC<{ const childCount = (node.children || []).reduce((total, child) => total + visitAndCount(child), 0); const totalCount = (isV2SidebarObjectNode(node) ? 1 : 0) + childCount; if (node.type === 'database') { - databaseObjectCounts.set(node.key, childCount); + const tableCount = (node.children || []).reduce((total, child) => { + if (child.type === 'object-group' && child?.dataRef?.groupKey === 'tables') { + return total + (Array.isArray(child.children) ? child.children.filter((item) => item.type === 'table').length : 0); + } + if (child?.dataRef?.groupKey === 'schema' && Array.isArray(child.children)) { + return total + child.children.reduce((schemaTotal, schemaChild) => { + if (schemaChild.type === 'object-group' && schemaChild?.dataRef?.groupKey === 'tables') { + return schemaTotal + (Array.isArray(schemaChild.children) ? schemaChild.children.filter((item) => item.type === 'table').length : 0); + } + return schemaTotal; + }, 0); + } + return total; + }, 0); + databaseTableCounts.set(node.key, tableCount); } else if (node.type === 'object-group') { objectGroupCounts.set(node.key, childCount); } @@ -5502,11 +5638,34 @@ const Sidebar: React.FC<{ return { activeObjectCount, - databaseObjectCounts, + databaseTableCounts, objectGroupCounts, }; }, [v2VisibleTreeData]); const activeConnectionObjectCount = v2TreeMetrics.activeObjectCount; + const legacyToolbarButtonColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)'; + const legacyToolbarStyle: React.CSSProperties = { + padding: '6px 16px', + display: 'grid', + gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', + gap: 8, + alignItems: 'center', + justifyItems: 'center', + borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, + borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, + background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)', + }; + const legacyToolbarItemStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 0, + }; + const legacyToolbarDisabledWrapStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + }; const connectionStatusMap = useMemo(() => { const statusMap = new Map(); @@ -5656,7 +5815,7 @@ const Sidebar: React.FC<{ return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'database') { - const count = v2TreeMetrics.databaseObjectCounts.get(node.key) || 0; + const count = v2TreeMetrics.databaseTableCounts.get(node.key) || 0; return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'object-group') { @@ -5668,9 +5827,8 @@ const Sidebar: React.FC<{ return match?.[1] || ''; } if (node.type === 'table') { - const key = `${node?.dataRef?.id}-${node?.dataRef?.dbName}-${node?.dataRef?.tableName}`; - const count = tableAccessCount[key] || 0; - return count > 0 ? count.toLocaleString() : ''; + const rowCount = Number(node?.dataRef?.rowCount); + return Number.isFinite(rowCount) && rowCount >= 0 ? formatSidebarRowCount(rowCount) : ''; } return ''; }; @@ -7766,65 +7924,75 @@ const Sidebar: React.FC<{ {/* Toolbar */} {!isV2Ui && ( -
- -
+
+ +
+
+ +
+
+ +
+
+ + +
)} diff --git a/frontend/src/components/Sidebar.v2-metadata.test.tsx b/frontend/src/components/Sidebar.v2-metadata.test.tsx new file mode 100644 index 0000000..cb5138a --- /dev/null +++ b/frontend/src/components/Sidebar.v2-metadata.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { formatSidebarRowCount } from './Sidebar'; + +describe('Sidebar v2 metadata', () => { + it('formats table row counts for sidebar labels', () => { + expect(formatSidebarRowCount(-1)).toBe(''); + expect(formatSidebarRowCount(0)).toBe('0'); + expect(formatSidebarRowCount(27)).toBe('27'); + expect(formatSidebarRowCount(1532)).toBe('1.5K'); + expect(formatSidebarRowCount(2_450_000)).toBe('2.5M'); + }); +}); diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index db86cd0..81b8097 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -773,6 +773,78 @@ describe('store appearance persistence', () => { expect(reloaded.useStore.getState().activeTabId).toBe('query-tab-1'); }); + it('updates activeContext when switching between tabs with different host or database', async () => { + const { useStore } = await importStore(); + + useStore.getState().addTab({ + id: 'table-main', + title: 'users', + type: 'table', + connectionId: 'conn-1', + dbName: 'sys', + tableName: 'users', + }); + expect(useStore.getState().activeContext).toEqual({ + connectionId: 'conn-1', + dbName: 'sys', + }); + + useStore.getState().addTab({ + id: 'query-bot', + title: '新建查询', + type: 'query', + connectionId: 'conn-2', + dbName: 'missav_bot', + query: 'select 1;', + }); + expect(useStore.getState().activeContext).toEqual({ + connectionId: 'conn-2', + dbName: 'missav_bot', + }); + + useStore.getState().setActiveTab('table-main'); + expect(useStore.getState().activeTabId).toBe('table-main'); + expect(useStore.getState().activeContext).toEqual({ + connectionId: 'conn-1', + dbName: 'sys', + }); + }); + + it('falls back activeContext to the new active tab after closing the current tab', async () => { + const { useStore } = await importStore(); + + useStore.getState().addTab({ + id: 'query-sys', + title: '新建查询', + type: 'query', + connectionId: 'conn-1', + dbName: 'sys', + query: 'select 1;', + }); + useStore.getState().addTab({ + id: 'query-bot', + title: '新建查询', + type: 'query', + connectionId: 'conn-2', + dbName: 'missav_bot', + query: 'select 2;', + }); + + expect(useStore.getState().activeTabId).toBe('query-bot'); + expect(useStore.getState().activeContext).toEqual({ + connectionId: 'conn-2', + dbName: 'missav_bot', + }); + + useStore.getState().closeTab('query-bot'); + + expect(useStore.getState().activeTabId).toBe('query-sys'); + expect(useStore.getState().activeContext).toEqual({ + connectionId: 'conn-1', + dbName: 'sys', + }); + }); + it('only restores persisted query tabs with useful SQL state', async () => { storage.setItem('lite-db-storage', JSON.stringify({ state: { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 40adbae..8ea9057 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1385,6 +1385,34 @@ const sanitizeActiveTabId = (activeTabId: unknown, tabs: TabData[]): string | nu return tabs[0]?.id || null; }; +const resolveActiveContextFromTab = ( + tab: TabData | null | undefined, +): { connectionId: string; dbName: string } | null => { + if (!tab) return null; + const connectionId = toTrimmedString(tab.connectionId); + if (!connectionId) return null; + return { + connectionId, + dbName: toTrimmedString(tab.dbName), + }; +}; + +const resolveActiveContextForTabId = ( + tabs: TabData[], + activeTabId: string | null | undefined, + fallbackContext: { connectionId: string; dbName: string } | null, +): { connectionId: string; dbName: string } | null => { + const normalizedActiveTabId = toTrimmedString(activeTabId); + if (normalizedActiveTabId) { + const activeTab = tabs.find((tab) => tab.id === normalizedActiveTabId); + const contextFromTab = resolveActiveContextFromTab(activeTab); + if (contextFromTab) { + return contextFromTab; + } + } + return fallbackContext; +}; + const sanitizeSqlLogs = (value: unknown, limit = MAX_PERSISTED_SQL_LOGS): SqlLog[] => { if (!Array.isArray(value)) return []; const result: SqlLog[] = []; @@ -2204,7 +2232,15 @@ export const useStore = create()( // Update existing tab with new data (e.g. switch initialTab) const newTabs = [...state.tabs]; newTabs[index] = { ...newTabs[index], ...tab }; - return { tabs: newTabs, activeTabId: tab.id }; + return { + tabs: newTabs, + activeTabId: tab.id, + activeContext: resolveActiveContextForTabId( + newTabs, + tab.id, + state.activeContext, + ), + }; } // 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab if ( @@ -2228,7 +2264,15 @@ export const useStore = create()( ...tab, id: existingTab.id, }; - return { tabs: newTabs, activeTabId: existingTab.id }; + return { + tabs: newTabs, + activeTabId: existingTab.id, + activeContext: resolveActiveContextForTabId( + newTabs, + existingTab.id, + state.activeContext, + ), + }; } } // 语义去重:对 query 类型按 savedQueryId 匹配已有 Tab(避免保存后重复打开) @@ -2247,10 +2291,27 @@ export const useStore = create()( ...tab, id: existingTab.id, }; - return { tabs: newTabs, activeTabId: existingTab.id }; + return { + tabs: newTabs, + activeTabId: existingTab.id, + activeContext: resolveActiveContextForTabId( + newTabs, + existingTab.id, + state.activeContext, + ), + }; } } - return { tabs: [...state.tabs, tab], activeTabId: tab.id }; + const nextTabs = [...state.tabs, tab]; + return { + tabs: nextTabs, + activeTabId: tab.id, + activeContext: resolveActiveContextForTabId( + nextTabs, + tab.id, + state.activeContext, + ), + }; }), updateQueryTabDraft: (id, draft) => @@ -2306,14 +2367,26 @@ export const useStore = create()( newActiveId = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null; } - return { tabs: newTabs, activeTabId: newActiveId }; + return { + tabs: newTabs, + activeTabId: newActiveId, + activeContext: resolveActiveContextForTabId( + newTabs, + newActiveId, + state.activeContext, + ), + }; }), closeOtherTabs: (id) => set((state) => { const keep = state.tabs.find((t) => t.id === id); if (!keep) return state; - return { tabs: [keep], activeTabId: id }; + return { + tabs: [keep], + activeTabId: id, + activeContext: resolveActiveContextFromTab(keep), + }; }), closeTabsToLeft: (id) => @@ -2327,6 +2400,11 @@ export const useStore = create()( return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id, + activeContext: resolveActiveContextForTabId( + newTabs, + activeStillExists ? state.activeTabId : id, + state.activeContext, + ), }; }), @@ -2341,6 +2419,11 @@ export const useStore = create()( return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id, + activeContext: resolveActiveContextForTabId( + newTabs, + activeStillExists ? state.activeTabId : id, + state.activeContext, + ), }; }), @@ -2359,14 +2442,18 @@ export const useStore = create()( : newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null; - const nextActiveContext = + const nextFallbackContext = state.activeContext?.connectionId === targetConnectionId ? null : state.activeContext; return { tabs: newTabs, activeTabId: nextActiveTabId, - activeContext: nextActiveContext, + activeContext: resolveActiveContextForTabId( + newTabs, + nextActiveTabId, + nextFallbackContext, + ), }; }), @@ -2393,10 +2480,17 @@ export const useStore = create()( state.activeContext && state.activeContext.connectionId === targetConnectionId && state.activeContext.dbName === targetDbName; + const nextFallbackContext = sameActiveContext + ? null + : state.activeContext; return { tabs: newTabs, activeTabId: nextActiveTabId, - activeContext: sameActiveContext ? null : state.activeContext, + activeContext: resolveActiveContextForTabId( + newTabs, + nextActiveTabId, + nextFallbackContext, + ), }; }), @@ -2418,9 +2512,17 @@ export const useStore = create()( return { tabs: nextTabs }; }), - closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })), + closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null, activeContext: null })), - setActiveTab: (id) => set({ activeTabId: id }), + setActiveTab: (id) => + set((state) => ({ + activeTabId: id, + activeContext: resolveActiveContextForTabId( + state.tabs, + id, + state.activeContext, + ), + })), setActiveContext: (context) => set({ activeContext: context }), saveQuery: (query) =>