feat(sidebar): 同步标签上下文并补充对象树统计信息

- 切换和关闭标签时同步 activeContext,避免新建查询误用 host 或数据库
- 侧边栏表节点展示行数统计,数据库节点展示表数量
- 旧版 sidebar 工具栏改为稳定五列布局,v1 不再混入 v2 置顶分组
- 补充 sidebar 与 store 回归测试
This commit is contained in:
Syngnat
2026-05-31 13:34:21 +08:00
parent e5fb03bbcd
commit 255e484dcf
5 changed files with 473 additions and 84 deletions

View File

@@ -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(<Sidebar />);
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(<Sidebar uiVersion="v2" sqlLogCount={2341} onCreateConnection={mocks.noop} />);
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 } },

View File

@@ -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<string, any>): 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<string, number>();
if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) {
tableStatsResult.data.forEach((row: Record<string, any>) => {
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: <TableOutlined />,
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<React.Key, number>();
const databaseTableCounts = new Map<React.Key, number>();
const objectGroupCounts = new Map<React.Key, number>();
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<string, 'live' | 'error' | 'idle'>();
@@ -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 && (
<div style={{ padding: '6px 16px', display: 'flex', gap: 8, 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)' }}>
<Tooltip title="新建组">
<Button
size="small"
type="text"
icon={<FolderOpenOutlined />}
aria-label="新建组"
data-sidebar-create-group-action="true"
onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }}
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
/>
</Tooltip>
<Tooltip title="批量操作表">
<Button
size="small"
type="text"
icon={<TableOutlined />}
aria-label="批量操作表"
data-sidebar-batch-table-action="true"
onClick={() => openBatchOperationModal()}
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
/>
</Tooltip>
<Tooltip title="批量操作库">
<Button
size="small"
type="text"
icon={<DatabaseOutlined />}
aria-label="批量操作库"
data-sidebar-batch-database-action="true"
onClick={() => openBatchDatabaseModal()}
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
/>
</Tooltip>
<Tooltip title="运行外部SQL文件">
<Button
size="small"
type="text"
icon={<FileAddOutlined />}
aria-label="运行外部 SQL 文件"
data-sidebar-open-external-sql-file-action="true"
onClick={handleOpenSQLFileFromToolbar}
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
/>
</Tooltip>
<Tooltip title={canLocateActiveTab ? '定位当前打开表' : '当前标签页没有可定位的表'}>
<span>
<div data-sidebar-legacy-toolbar="true" style={legacyToolbarStyle}>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title="新建组">
<Button
size="small"
type="text"
icon={<AimOutlined />}
aria-label="定位当前打开表"
data-sidebar-locate-current-tab-action="true"
disabled={!canLocateActiveTab}
onClick={handleLocateActiveTabInSidebar}
style={{ color: darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)' }}
icon={<FolderOpenOutlined />}
aria-label="新建组"
data-sidebar-create-group-action="true"
onClick={() => { setRenameViewTarget(null); createTagForm.resetFields(); setIsCreateTagModalOpen(true); }}
style={{ color: legacyToolbarButtonColor }}
/>
</span>
</Tooltip>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title="批量操作表">
<Button
size="small"
type="text"
icon={<TableOutlined />}
aria-label="批量操作表"
data-sidebar-batch-table-action="true"
onClick={() => openBatchOperationModal()}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title="批量操作库">
<Button
size="small"
type="text"
icon={<DatabaseOutlined />}
aria-label="批量操作库"
data-sidebar-batch-database-action="true"
onClick={() => openBatchDatabaseModal()}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title="运行外部SQL文件">
<Button
size="small"
type="text"
icon={<FileAddOutlined />}
aria-label="运行外部 SQL 文件"
data-sidebar-open-external-sql-file-action="true"
onClick={handleOpenSQLFileFromToolbar}
style={{ color: legacyToolbarButtonColor }}
/>
</Tooltip>
</div>
<div data-sidebar-legacy-toolbar-item="true" style={legacyToolbarItemStyle}>
<Tooltip title={canLocateActiveTab ? '定位当前打开表' : '当前标签页没有可定位的表'}>
<span style={legacyToolbarDisabledWrapStyle}>
<Button
size="small"
type="text"
icon={<AimOutlined />}
aria-label="定位当前打开表"
data-sidebar-locate-current-tab-action="true"
disabled={!canLocateActiveTab}
onClick={handleLocateActiveTabInSidebar}
style={{ color: legacyToolbarButtonColor }}
/>
</span>
</Tooltip>
</div>
</div>
)}

View File

@@ -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');
});
});

View File

@@ -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: {

View File

@@ -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<AppState>()(
// 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<AppState>()(
...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<AppState>()(
...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<AppState>()(
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<AppState>()(
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<AppState>()(
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<AppState>()(
: 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<AppState>()(
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<AppState>()(
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) =>