mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-13 01:49:41 +08:00
✨ feat(sidebar): 同步标签上下文并补充对象树统计信息
- 切换和关闭标签时同步 activeContext,避免新建查询误用 host 或数据库 - 侧边栏表节点展示行数统计,数据库节点展示表数量 - 旧版 sidebar 工具栏改为稳定五列布局,v1 不再混入 v2 置顶分组 - 补充 sidebar 与 store 回归测试
This commit is contained in:
@@ -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 } },
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
13
frontend/src/components/Sidebar.v2-metadata.test.tsx
Normal file
13
frontend/src/components/Sidebar.v2-metadata.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user