mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
✨ feat(sidebar): 新增数据库置顶与分区展示 (#803)
## 背景 Issue #788 希望侧边栏数据库具备与数据表一致的置顶能力,方便快速访问常用数据库。 ## 变更内容 - 在 V2 数据库右键菜单中新增“置顶数据库 / 取消置顶数据库”操作 - 持久化数据库置顶状态,重启后仍可恢复 - 按“置顶 / 全部”分区展示数据库,并保留原始排序 - 取消置顶后恢复数据库原位置,同时保留已经加载的子树 - 数据库折叠后不再清空子树,重新展开无需再次连接 - 补充六种语言文案及侧边栏、加载器和 Store 回归测试 ## 影响范围 仅调整 V2 侧边栏的数据库树展示与交互,不包含 wails dev 生成文件。 ## 验证 - px vitest run src/components/Sidebar.locate-toolbar.test.tsx src/components/sidebarV2Utils.command-search.test.ts src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx src/components/sidebar/useSidebarTreeLoaders.nacos-services.test.tsx src/store.test.ts --testTimeout=60000(205 项通过) - px tsc --noEmit --pretty false - pm run build(7976 个模块构建成功) - git diff --check origin/dev...HEAD Closes #788
This commit is contained in:
@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { readV2ThemeCss } from '../test/readV2ThemeCss';
|
||||
|
||||
import Sidebar, {
|
||||
applySidebarDatabasePinning,
|
||||
buildAllSavedQueriesTreeNode,
|
||||
buildSidebarConnectionTagTree,
|
||||
buildSidebarTableChildrenForUi,
|
||||
buildV2SidebarDatabaseSectionedChildren,
|
||||
buildV2SidebarTableSectionedChildren,
|
||||
buildSQLFileExecutionFooter,
|
||||
buildV2RailConnectionGroups,
|
||||
@@ -16,6 +18,7 @@ import Sidebar, {
|
||||
filterV2ExplorerTreeByKind,
|
||||
getV2RailConnectionGroupBadgeText,
|
||||
hasSidebarLazyChildren,
|
||||
isSidebarDatabasePinned,
|
||||
isConnectionTagDescendant,
|
||||
normalizeSidebarTreeRelativeDropPosition,
|
||||
parseV2CommandSearchQuery,
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
V2_EXPLORER_FILTER_OPTIONS as V2_UTILS_EXPLORER_FILTER_OPTIONS,
|
||||
} from './sidebarV2Utils';
|
||||
import {
|
||||
buildSidebarDatabasePinKey,
|
||||
buildSidebarRootConnectionToken,
|
||||
buildSidebarRootTagToken,
|
||||
buildSidebarTablePinKey,
|
||||
@@ -132,6 +136,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
buildSidebarDatabasePinKey: (
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
) => JSON.stringify([connectionId.trim(), dbName.trim()]),
|
||||
buildSidebarRootConnectionToken: (connectionId: string) => `connection:${connectionId.trim()}`,
|
||||
buildSidebarRootTagToken: (tagId: string) => `tag:${tagId.trim()}`,
|
||||
resolveConnectionTagChildOrder: (
|
||||
@@ -193,6 +201,18 @@ vi.mock('../store', () => ({
|
||||
schemaName.trim(),
|
||||
tableName.trim(),
|
||||
]),
|
||||
updateSidebarDatabasePinKeys: (
|
||||
pinnedKeys: string[],
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
pinned: boolean,
|
||||
) => {
|
||||
const key = JSON.stringify([connectionId.trim(), dbName.trim()]);
|
||||
const next = new Set(pinnedKeys);
|
||||
if (pinned) next.add(key);
|
||||
else next.delete(key);
|
||||
return Array.from(next);
|
||||
},
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: mocks.state.connections,
|
||||
savedQueries: [],
|
||||
@@ -231,9 +251,11 @@ vi.mock('../store', () => ({
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
pinnedSidebarTables: [],
|
||||
pinnedSidebarDatabases: [],
|
||||
recordTableAccess: mocks.noop,
|
||||
setTableSortPreference: mocks.noop,
|
||||
setSidebarTablePinned: mocks.noop,
|
||||
setSidebarDatabasePinned: mocks.noop,
|
||||
queryOptions: { showSidebarTableComment: false },
|
||||
setQueryOptions: mocks.noop,
|
||||
addSqlLog: mocks.noop,
|
||||
@@ -1589,6 +1611,134 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(markup).not.toContain('置顶表');
|
||||
});
|
||||
|
||||
it('renders the v2 database context menu pin and unpin states', () => {
|
||||
const unpinnedMarkup = renderToStaticMarkup(
|
||||
<V2DatabaseContextMenuView dbName="analytics" />,
|
||||
);
|
||||
const pinnedMarkup = renderToStaticMarkup(
|
||||
<V2DatabaseContextMenuView dbName="analytics" isPinned />,
|
||||
);
|
||||
|
||||
expect(unpinnedMarkup).toContain('置顶数据库');
|
||||
expect(unpinnedMarkup).not.toContain('取消置顶数据库');
|
||||
expect(pinnedMarkup).toContain('取消置顶数据库');
|
||||
expect(pinnedMarkup).toContain('已置顶');
|
||||
});
|
||||
|
||||
it('wires database pin actions to persistence and in-memory tree reordering', () => {
|
||||
const actionSource = readSourceFile('./sidebar/useSidebarV2ActionHandlers.tsx');
|
||||
const contextMenuSource = readSourceFile('./sidebar/useSidebarV2ContextMenu.tsx');
|
||||
const loaderSource = readSourceFile('./sidebar/useSidebarTreeLoaders.tsx');
|
||||
|
||||
expect(actionSource).toContain("case 'pin-database':");
|
||||
expect(actionSource).toContain("case 'unpin-database':");
|
||||
expect(actionSource).toContain('setSidebarDatabasePinned(connectionId, dbName, shouldPin);');
|
||||
expect(actionSource).toContain('applySidebarDatabasePinning(');
|
||||
expect(actionSource).toContain('buildV2SidebarDatabaseSectionedChildren(');
|
||||
expect(loaderSource).toContain('buildV2SidebarDatabaseSectionedChildren(');
|
||||
expect(contextMenuSource).toContain('isSidebarDatabasePinned(');
|
||||
expect(contextMenuSource).toContain('isPinned={isPinned}');
|
||||
});
|
||||
|
||||
it('moves pinned databases first while preserving loaded database children', () => {
|
||||
const pinnedSidebarDatabases = [
|
||||
buildSidebarDatabasePinKey('conn-1', 'analytics'),
|
||||
];
|
||||
const loadedChildren = [{ title: 'Tables', key: 'analytics-tables', type: 'object-group' as const }];
|
||||
const nodes = [
|
||||
{ title: 'archive', key: 'conn-1-archive', type: 'database' as const, dataRef: { id: 'conn-1', dbName: 'archive' } },
|
||||
{ title: 'analytics', key: 'conn-1-analytics', type: 'database' as const, dataRef: { id: 'conn-1', dbName: 'analytics' }, children: loadedChildren },
|
||||
{ title: 'system', key: 'conn-1-system', type: 'database' as const, dataRef: { id: 'conn-1', dbName: 'system' } },
|
||||
];
|
||||
|
||||
expect(isSidebarDatabasePinned(pinnedSidebarDatabases, 'conn-1', 'analytics')).toBe(true);
|
||||
const result = applySidebarDatabasePinning(nodes, {
|
||||
connectionId: 'conn-1',
|
||||
pinnedSidebarDatabases,
|
||||
});
|
||||
|
||||
expect(result.map((node) => node.title)).toEqual(['analytics', 'archive', 'system']);
|
||||
expect(result[0].dataRef?.pinnedSidebarDatabase).toBe(true);
|
||||
expect(result[0].children).toBe(loadedChildren);
|
||||
expect(result[1].dataRef?.pinnedSidebarDatabase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores a database to its original position after unpinning', () => {
|
||||
const pinKey = buildSidebarDatabasePinKey('conn-1', 'analytics');
|
||||
const loadedChildren = [{ title: 'Tables', key: 'analytics-tables', type: 'object-group' as const }];
|
||||
const nodes = [
|
||||
{ title: 'archive', key: 'conn-1-archive', type: 'database' as const, dataRef: { id: 'conn-1', dbName: 'archive' } },
|
||||
{ title: 'analytics', key: 'conn-1-analytics', type: 'database' as const, dataRef: { id: 'conn-1', dbName: 'analytics' }, children: loadedChildren },
|
||||
{ title: 'system', key: 'conn-1-system', type: 'database' as const, dataRef: { id: 'conn-1', dbName: 'system' } },
|
||||
];
|
||||
|
||||
const pinned = applySidebarDatabasePinning(nodes, {
|
||||
connectionId: 'conn-1',
|
||||
pinnedSidebarDatabases: [pinKey],
|
||||
});
|
||||
const unpinned = applySidebarDatabasePinning(pinned, {
|
||||
connectionId: 'conn-1',
|
||||
pinnedSidebarDatabases: [],
|
||||
});
|
||||
|
||||
expect(pinned.map((node) => node.title)).toEqual(['analytics', 'archive', 'system']);
|
||||
expect(unpinned.map((node) => node.title)).toEqual(['archive', 'analytics', 'system']);
|
||||
expect(unpinned[1].children).toBe(loadedChildren);
|
||||
expect(unpinned[1].dataRef?.pinnedSidebarDatabase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('splits pinned databases into pinned and all sections', () => {
|
||||
setCurrentLanguage('en-US');
|
||||
const databaseNodes = [
|
||||
{ title: 'analytics', key: 'conn-1-analytics', type: 'database' as const, dataRef: { pinnedSidebarDatabase: true } },
|
||||
{ title: 'archive', key: 'conn-1-archive', type: 'database' as const, dataRef: {} },
|
||||
];
|
||||
|
||||
const children = buildV2SidebarDatabaseSectionedChildren('conn-1', databaseNodes);
|
||||
|
||||
expect(children.map((node) => node.title)).toEqual(['Pinned', 'analytics', 'All', 'archive']);
|
||||
expect(children.map((node) => node.type)).toEqual([
|
||||
'v2-database-section',
|
||||
'database',
|
||||
'v2-database-section',
|
||||
'database',
|
||||
]);
|
||||
expect(children[0]).toMatchObject({
|
||||
key: 'conn-1-v2-pinned-databases-section',
|
||||
isLeaf: true,
|
||||
selectable: false,
|
||||
dataRef: { sectionKind: 'pinned' },
|
||||
});
|
||||
expect(children[2]).toMatchObject({
|
||||
key: 'conn-1-v2-all-databases-section',
|
||||
isLeaf: true,
|
||||
selectable: false,
|
||||
dataRef: { sectionKind: 'all' },
|
||||
});
|
||||
const sectionMarkup = renderToStaticMarkup(renderSidebarV2TreeTitle({
|
||||
node: children[0],
|
||||
hoverTitle: 'Pinned',
|
||||
statusBadge: null,
|
||||
getV2TreeMetaText: () => '',
|
||||
sidebarTableMetadataFields: [],
|
||||
snapshotTreeSelectionBeforeDrag: vi.fn(),
|
||||
restoreTreeSelectionAfterDrag: vi.fn(),
|
||||
treeDragSelectSuppressUntilRef: { current: 0 },
|
||||
setIsTreeDragging: vi.fn(),
|
||||
}));
|
||||
expect(sectionMarkup).toContain('class="gn-v2-tree-section-title"');
|
||||
expect(sectionMarkup).toContain('data-section-kind="pinned"');
|
||||
expect(sectionMarkup).toContain('Pinned');
|
||||
expect(buildV2SidebarDatabaseSectionedChildren('conn-1', children).map((node) => node.title))
|
||||
.toEqual(['Pinned', 'analytics', 'All', 'archive']);
|
||||
|
||||
const unpinnedNodes = databaseNodes.map((node) => ({
|
||||
...node,
|
||||
dataRef: {},
|
||||
}));
|
||||
expect(buildV2SidebarDatabaseSectionedChildren('conn-1', unpinnedNodes)).toBe(unpinnedNodes);
|
||||
});
|
||||
|
||||
it('sorts sidebar table names in natural numeric order', () => {
|
||||
const entries = [
|
||||
{ tableName: 'table_10', displayName: 'table_10' },
|
||||
@@ -1666,6 +1816,36 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(css).not.toContain('.gn-v2-table-pin-action');
|
||||
});
|
||||
|
||||
it('renders the same non-interactive pin indicator for pinned databases', () => {
|
||||
const baseOptions = {
|
||||
hoverTitle: 'analytics',
|
||||
statusBadge: null,
|
||||
getV2TreeMetaText: () => '',
|
||||
sidebarTableMetadataFields: [],
|
||||
snapshotTreeSelectionBeforeDrag: vi.fn(),
|
||||
restoreTreeSelectionAfterDrag: vi.fn(),
|
||||
treeDragSelectSuppressUntilRef: { current: 0 },
|
||||
setIsTreeDragging: vi.fn(),
|
||||
};
|
||||
const renderDatabaseTitle = (pinnedSidebarDatabase: boolean) => renderToStaticMarkup(
|
||||
renderSidebarV2TreeTitle({
|
||||
...baseOptions,
|
||||
node: {
|
||||
type: 'database',
|
||||
title: 'analytics',
|
||||
key: 'conn-1-analytics',
|
||||
dataRef: { id: 'conn-1', dbName: 'analytics', pinnedSidebarDatabase },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(renderDatabaseTitle(false)).not.toContain('data-v2-sidebar-database-pin-indicator');
|
||||
const pinnedMarkup = renderDatabaseTitle(true);
|
||||
expect(pinnedMarkup).toContain('data-v2-sidebar-database-pin-indicator="true"');
|
||||
expect(pinnedMarkup).toContain('gn-v2-database-pin-indicator');
|
||||
expect(pinnedMarkup).toContain(`aria-label="${t('sidebar.status.pinned')}"`);
|
||||
});
|
||||
|
||||
it('splits v2 sidebar pinned tables into a dedicated table section', () => {
|
||||
const source = readSidebarSource();
|
||||
const sectionBuilderSourceStart = source.indexOf('export const buildV2SidebarTableSectionedChildren = (');
|
||||
|
||||
@@ -187,14 +187,17 @@ import {
|
||||
export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils';
|
||||
export type { ExternalSQLFileModalMode, SearchScope } from './sidebarCoreUtils';
|
||||
import {
|
||||
applySidebarDatabasePinning,
|
||||
buildSidebarTableChildrenForUi,
|
||||
buildSidebarConnectionTagTree,
|
||||
buildV2RailConnectionGroups,
|
||||
buildV2SidebarDatabaseSectionedChildren,
|
||||
buildV2SidebarTableSectionedChildren,
|
||||
collectSidebarSubtreeKeys,
|
||||
estimateV2TreeHorizontalScrollWidth,
|
||||
filterV2CommandSearchTreeItems,
|
||||
filterV2ExplorerTreeByKind,
|
||||
isSidebarDatabasePinned,
|
||||
isSidebarTablePinned,
|
||||
isConnectionTagDescendant,
|
||||
normalizeSidebarTreeRelativeDropPosition,
|
||||
@@ -222,14 +225,17 @@ import {
|
||||
} from './sidebarV2Utils';
|
||||
|
||||
export {
|
||||
applySidebarDatabasePinning,
|
||||
buildSidebarTableChildrenForUi,
|
||||
buildSidebarConnectionTagTree,
|
||||
buildV2RailConnectionGroups,
|
||||
buildV2SidebarDatabaseSectionedChildren,
|
||||
buildV2SidebarTableSectionedChildren,
|
||||
collectSidebarSubtreeKeys,
|
||||
estimateV2TreeHorizontalScrollWidth,
|
||||
filterV2CommandSearchTreeItems,
|
||||
filterV2ExplorerTreeByKind,
|
||||
isSidebarDatabasePinned,
|
||||
isSidebarTablePinned,
|
||||
isConnectionTagDescendant,
|
||||
normalizeSidebarTreeRelativeDropPosition,
|
||||
@@ -621,9 +627,11 @@ const Sidebar: React.FC<{
|
||||
const tableAccessCount = useStore(state => state.tableAccessCount);
|
||||
const tableSortPreference = useStore(state => state.tableSortPreference);
|
||||
const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables);
|
||||
const pinnedSidebarDatabases = useStore(state => state.pinnedSidebarDatabases);
|
||||
const recordTableAccess = useStore(state => state.recordTableAccess);
|
||||
const setTableSortPreference = useStore(state => state.setTableSortPreference);
|
||||
const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned);
|
||||
const setSidebarDatabasePinned = useStore(state => state.setSidebarDatabasePinned);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
@@ -1801,7 +1809,7 @@ const Sidebar: React.FC<{
|
||||
};
|
||||
|
||||
const onSelect = (keys: React.Key[], info: any) => {
|
||||
if (isV2Ui && info?.node?.type === 'v2-table-section') {
|
||||
if (isV2Ui && (info?.node?.type === 'v2-table-section' || info?.node?.type === 'v2-database-section')) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() < treeDragSelectSuppressUntilRef.current) {
|
||||
@@ -1906,7 +1914,7 @@ const Sidebar: React.FC<{
|
||||
clickTimerRef.current = null;
|
||||
}
|
||||
const { type, dataRef, key: nodeKey } = node;
|
||||
if (isV2Ui && type === 'v2-table-section') {
|
||||
if (isV2Ui && (type === 'v2-table-section' || type === 'v2-database-section')) {
|
||||
return;
|
||||
}
|
||||
const nodeConnectionId = resolveSidebarNodeConnectionId(node, connectionIds);
|
||||
@@ -2274,6 +2282,7 @@ const Sidebar: React.FC<{
|
||||
tableSortPreference,
|
||||
tableAccessCount,
|
||||
pinnedSidebarTables,
|
||||
pinnedSidebarDatabases,
|
||||
isV2Ui,
|
||||
loadingNodesRef,
|
||||
setConnectionStates,
|
||||
@@ -2604,6 +2613,7 @@ const Sidebar: React.FC<{
|
||||
connections,
|
||||
connectionTags,
|
||||
pinnedSidebarTables,
|
||||
pinnedSidebarDatabases,
|
||||
loadingNodesRef,
|
||||
treeDataRef,
|
||||
findTreeNodeByKeyRef,
|
||||
@@ -2629,6 +2639,7 @@ const Sidebar: React.FC<{
|
||||
removeConnectionTag,
|
||||
moveConnectionToTag,
|
||||
setSidebarTablePinned,
|
||||
setSidebarDatabasePinned,
|
||||
setTableSortPreference,
|
||||
replaceTreeNodeChildren,
|
||||
loadDatabases,
|
||||
@@ -2759,6 +2770,7 @@ const Sidebar: React.FC<{
|
||||
v2TreeMetrics,
|
||||
tableSortPreference,
|
||||
pinnedSidebarTables,
|
||||
pinnedSidebarDatabases,
|
||||
getConnectionNodeForAction,
|
||||
buildRuntimeConfig,
|
||||
extractObjectName,
|
||||
@@ -3033,7 +3045,7 @@ const Sidebar: React.FC<{
|
||||
};
|
||||
|
||||
const onRightClick = ({ event, node }: any) => {
|
||||
if (isV2Ui && node?.type === 'v2-table-section') {
|
||||
if (isV2Ui && (node?.type === 'v2-table-section' || node?.type === 'v2-database-section')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
|
||||
@@ -325,6 +325,8 @@ export const V2TableGroupContextMenuView: React.FC<{
|
||||
};
|
||||
|
||||
export type V2DatabaseContextMenuActionKey =
|
||||
| 'pin-database'
|
||||
| 'unpin-database'
|
||||
| 'copy-database-name'
|
||||
| 'new-table'
|
||||
| 'new-schema'
|
||||
@@ -356,6 +358,7 @@ export const V2DatabaseContextMenuView: React.FC<{
|
||||
supportsStarRocksActions?: boolean;
|
||||
supportsRenameDatabase?: boolean;
|
||||
supportsDropDatabase?: boolean;
|
||||
isPinned?: boolean;
|
||||
onAction?: (action: V2DatabaseContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
dbName,
|
||||
@@ -366,6 +369,7 @@ export const V2DatabaseContextMenuView: React.FC<{
|
||||
supportsStarRocksActions = false,
|
||||
supportsRenameDatabase = true,
|
||||
supportsDropDatabase = true,
|
||||
isPinned = false,
|
||||
onAction,
|
||||
}) => {
|
||||
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
|
||||
@@ -385,6 +389,7 @@ export const V2DatabaseContextMenuView: React.FC<{
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'copy-database-name', icon: <CopyOutlined />, title: t('sidebar.menu.copy_database_name'), kbd: primaryShortcut('C', shortcutPlatform), featured: true },
|
||||
{ action: isPinned ? 'unpin-database' : 'pin-database', icon: <PushpinOutlined />, title: isPinned ? t('sidebar.action.unpin_database') : t('sidebar.action.pin_database'), kbd: isPinned ? t('sidebar.status.pinned') : undefined, selected: isPinned },
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: t('sidebar.menu.create_table'), kbd: primaryShortcut('N', shortcutPlatform), featured: true },
|
||||
...(supportsSchemaActions ? [{ action: 'new-schema', icon: <FolderAddOutlined />, title: t('sidebar.v2_database_menu.new_schema') }] : []),
|
||||
...(supportsSchemaVisibility ? [{ action: 'schema-visibility', icon: <FolderOpenOutlined />, title: t('sidebar.schema_visibility.menu.manage') }] : []),
|
||||
|
||||
@@ -123,7 +123,7 @@ export const renderSidebarV2TreeTitle = ({
|
||||
const rawTitle = String(node.title ?? '');
|
||||
const groupKey = String(node?.dataRef?.groupKey || '');
|
||||
const dragText = resolveSidebarObjectDragText(node);
|
||||
if (node.type === 'v2-table-section') {
|
||||
if (node.type === 'v2-table-section' || node.type === 'v2-database-section') {
|
||||
return (
|
||||
<span
|
||||
className="gn-v2-tree-section-title"
|
||||
@@ -176,13 +176,19 @@ export const renderSidebarV2TreeTitle = ({
|
||||
node.type === 'redis-db' ? 'is-redis-db' : '',
|
||||
node.type === 'table' && node?.dataRef?.pinnedSidebarTable ? 'is-pinned-table' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const tablePinIndicator = node.type === 'table' && node?.dataRef?.pinnedSidebarTable ? (
|
||||
const pinnedNodeType = node.type === 'table' && node?.dataRef?.pinnedSidebarTable
|
||||
? 'table'
|
||||
: node.type === 'database' && node?.dataRef?.pinnedSidebarDatabase
|
||||
? 'database'
|
||||
: null;
|
||||
const pinIndicator = pinnedNodeType ? (
|
||||
<span
|
||||
className="gn-v2-table-pin-indicator"
|
||||
className={`gn-v2-table-pin-indicator${pinnedNodeType === 'database' ? ' gn-v2-database-pin-indicator' : ''}`}
|
||||
title={t('sidebar.status.pinned')}
|
||||
role="img"
|
||||
aria-label={t('sidebar.status.pinned')}
|
||||
data-v2-sidebar-table-pin-indicator="true"
|
||||
data-v2-sidebar-table-pin-indicator={pinnedNodeType === 'table' ? 'true' : undefined}
|
||||
data-v2-sidebar-database-pin-indicator={pinnedNodeType === 'database' ? 'true' : undefined}
|
||||
>
|
||||
<StarFilled aria-hidden="true" />
|
||||
</span>
|
||||
@@ -268,7 +274,7 @@ export const renderSidebarV2TreeTitle = ({
|
||||
return (
|
||||
<>
|
||||
{wrappedTitleNode}
|
||||
{tablePinIndicator}
|
||||
{pinIndicator}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
|
||||
tableSortPreference: {} as Record<string, string>,
|
||||
tableAccessCount: {} as Record<string, number>,
|
||||
pinnedSidebarTables: [] as string[],
|
||||
pinnedSidebarDatabases: [] as string[],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -85,6 +86,7 @@ describe('useSidebarTreeLoaders Nacos service groups', () => {
|
||||
tableSortPreference: {},
|
||||
tableAccessCount: {},
|
||||
pinnedSidebarTables: [],
|
||||
pinnedSidebarDatabases: [],
|
||||
isV2Ui: true,
|
||||
loadingNodesRef,
|
||||
setConnectionStates: vi.fn(),
|
||||
@@ -168,6 +170,7 @@ describe('useSidebarTreeLoaders Nacos namespace discovery', () => {
|
||||
tableSortPreference: {},
|
||||
tableAccessCount: {},
|
||||
pinnedSidebarTables: [],
|
||||
pinnedSidebarDatabases: [],
|
||||
isV2Ui: true,
|
||||
loadingNodesRef,
|
||||
setConnectionStates,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SavedConnection } from '../../types';
|
||||
import { buildSidebarDatabasePinKey } from '../../store';
|
||||
import { useSidebarTreeLoaders } from './useSidebarTreeLoaders';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => ({
|
||||
tableSortPreference: {} as Record<string, string>,
|
||||
tableAccessCount: {} as Record<string, number>,
|
||||
pinnedSidebarTables: [] as string[],
|
||||
pinnedSidebarDatabases: [] as string[],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -52,6 +54,7 @@ describe('useSidebarTreeLoaders PostgreSQL partitions', () => {
|
||||
mocks.storeState.tableSortPreference = {};
|
||||
mocks.storeState.tableAccessCount = {};
|
||||
mocks.storeState.pinnedSidebarTables = [];
|
||||
mocks.storeState.pinnedSidebarDatabases = [];
|
||||
mocks.replaceTreeNodeChildren.mockImplementation((_key, children) => children || []);
|
||||
});
|
||||
|
||||
@@ -60,6 +63,77 @@ describe('useSidebarTreeLoaders PostgreSQL partitions', () => {
|
||||
renderer = null;
|
||||
});
|
||||
|
||||
it('loads pinned databases first from the latest persisted pin state', async () => {
|
||||
const connection = {
|
||||
id: 'conn-mysql',
|
||||
name: 'MySQL',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
},
|
||||
} as SavedConnection;
|
||||
mocks.storeState.connections = [connection];
|
||||
mocks.storeState.pinnedSidebarDatabases = [
|
||||
buildSidebarDatabasePinKey(connection.id, 'analytics'),
|
||||
];
|
||||
mocks.dbGetDatabases.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{ Database: 'archive' },
|
||||
{ Database: 'analytics' },
|
||||
{ Database: 'system' },
|
||||
],
|
||||
});
|
||||
|
||||
let loaders: ReturnType<typeof useSidebarTreeLoaders> | undefined;
|
||||
const Harness = () => {
|
||||
loaders = useSidebarTreeLoaders({
|
||||
savedQueries: [],
|
||||
tableSortPreference: {},
|
||||
tableAccessCount: {},
|
||||
pinnedSidebarTables: [],
|
||||
pinnedSidebarDatabases: [],
|
||||
isV2Ui: true,
|
||||
loadingNodesRef: { current: new Set<string>() },
|
||||
setConnectionStates: vi.fn(),
|
||||
setLoadedKeys: vi.fn(),
|
||||
replaceTreeNodeChildren: mocks.replaceTreeNodeChildren,
|
||||
buildRuntimeConfig: (conn) => conn.config,
|
||||
buildJVMRuntimeConfig: (conn) => conn.config,
|
||||
buildJVMDiagnosticTreeNodes: () => [],
|
||||
resolveSavedQueryDisplayName: (name) => String(name || ''),
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
renderer = create(<Harness />);
|
||||
});
|
||||
await act(async () => {
|
||||
await loaders?.loadDatabases({ key: connection.id, dataRef: connection });
|
||||
});
|
||||
|
||||
const [, databaseNodes] = mocks.replaceTreeNodeChildren.mock.calls[0];
|
||||
expect(databaseNodes.map((node: any) => node.type)).toEqual([
|
||||
'v2-database-section',
|
||||
'database',
|
||||
'v2-database-section',
|
||||
'database',
|
||||
'database',
|
||||
]);
|
||||
expect(databaseNodes
|
||||
.filter((node: any) => node.type === 'database')
|
||||
.map((node: any) => node.title)).toEqual([
|
||||
'analytics',
|
||||
'archive',
|
||||
'system',
|
||||
]);
|
||||
expect(databaseNodes[1].dataRef.pinnedSidebarDatabase).toBe(true);
|
||||
expect(databaseNodes[3].dataRef.pinnedSidebarDatabase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds a Partitions group with clickable table nodes and hides the parent row count', async () => {
|
||||
const connection = {
|
||||
id: 'conn-pg',
|
||||
@@ -113,6 +187,7 @@ describe('useSidebarTreeLoaders PostgreSQL partitions', () => {
|
||||
tableSortPreference: {},
|
||||
tableAccessCount: {},
|
||||
pinnedSidebarTables: [],
|
||||
pinnedSidebarDatabases: [],
|
||||
isV2Ui: true,
|
||||
loadingNodesRef: { current: new Set<string>() },
|
||||
setConnectionStates: vi.fn(),
|
||||
|
||||
@@ -52,7 +52,9 @@ import {
|
||||
supportsDatabaseSequences,
|
||||
} from './sidebarMetadataLoaders';
|
||||
import {
|
||||
applySidebarDatabasePinning,
|
||||
buildSidebarTableChildrenForUi,
|
||||
buildV2SidebarDatabaseSectionedChildren,
|
||||
isSidebarTablePinned,
|
||||
sortSidebarTableEntries,
|
||||
type SidebarConnectionState,
|
||||
@@ -157,6 +159,7 @@ type UseSidebarTreeLoadersOptions = {
|
||||
tableSortPreference: Record<string, any>;
|
||||
tableAccessCount: Record<string, any>;
|
||||
pinnedSidebarTables: any[];
|
||||
pinnedSidebarDatabases: string[];
|
||||
isV2Ui: boolean;
|
||||
loadingNodesRef: React.MutableRefObject<Set<string>>;
|
||||
setConnectionStates: React.Dispatch<React.SetStateAction<Record<string, SidebarConnectionState>>>;
|
||||
@@ -174,6 +177,7 @@ export const useSidebarTreeLoaders = ({
|
||||
tableSortPreference,
|
||||
tableAccessCount,
|
||||
pinnedSidebarTables,
|
||||
pinnedSidebarDatabases,
|
||||
isV2Ui,
|
||||
loadingNodesRef,
|
||||
setConnectionStates,
|
||||
@@ -576,7 +580,7 @@ export const useSidebarTreeLoaders = ({
|
||||
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
|
||||
if (res.success) {
|
||||
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
|
||||
let dbs = dbRows.map((row: any) => ({
|
||||
let dbs: TreeNode[] = dbRows.map((row: any) => ({
|
||||
title: row.Database || row.database,
|
||||
key: `${conn.id}-${row.Database || row.database}`,
|
||||
icon: <DatabaseOutlined />,
|
||||
@@ -590,6 +594,18 @@ export const useSidebarTreeLoaders = ({
|
||||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
|
||||
}
|
||||
|
||||
if (isV2Ui) {
|
||||
const currentPinnedSidebarDatabases =
|
||||
useStore.getState().pinnedSidebarDatabases || pinnedSidebarDatabases;
|
||||
dbs = buildV2SidebarDatabaseSectionedChildren(
|
||||
String(node.key),
|
||||
applySidebarDatabasePinning(dbs, {
|
||||
connectionId: conn.id,
|
||||
pinnedSidebarDatabases: currentPinnedSidebarDatabases,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (dbs.length > 0) {
|
||||
replaceTreeNodeChildren(node.key, dbs, conn);
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveConnectionAccentColor, resolveConnectionIconType } from '../../u
|
||||
import { normalizeConnectionEnvironmentType } from '../../utils/connectionEnvironment';
|
||||
import { resolveTableSelectQuery } from '../../utils/objectQueryTemplates';
|
||||
import { DBReleaseConnection } from '../../../wailsjs/go/app/App';
|
||||
import { updateSidebarDatabasePinKeys } from '../../store';
|
||||
import { getDbIcon } from '../DatabaseIcons';
|
||||
import { getMetadataDialect } from './sidebarMetadataLoaders';
|
||||
import {
|
||||
@@ -20,6 +21,9 @@ import {
|
||||
type V2TableGroupContextMenuActionKey,
|
||||
} from '../V2TableContextMenu';
|
||||
import {
|
||||
applySidebarDatabasePinning,
|
||||
buildV2SidebarDatabaseSectionedChildren,
|
||||
isSidebarDatabasePinned,
|
||||
isSidebarTablePinned,
|
||||
type SidebarConnectionState,
|
||||
type SidebarTreeNode as TreeNode,
|
||||
@@ -30,6 +34,7 @@ type UseSidebarV2ActionHandlersArgs = {
|
||||
connections: SavedConnection[];
|
||||
connectionTags: ConnectionTag[];
|
||||
pinnedSidebarTables: any[];
|
||||
pinnedSidebarDatabases: string[];
|
||||
loadingNodesRef: MutableRefObject<Set<string>>;
|
||||
treeDataRef: MutableRefObject<TreeNode[]>;
|
||||
findTreeNodeByKeyRef: MutableRefObject<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>;
|
||||
@@ -55,6 +60,7 @@ type UseSidebarV2ActionHandlersArgs = {
|
||||
removeConnectionTag: (tagId: string) => void;
|
||||
moveConnectionToTag: (connectionId: string, tagId: string | null) => void;
|
||||
setSidebarTablePinned: (connectionId: string, dbName: string, tableName: string, schemaName: string, pinned: boolean) => void;
|
||||
setSidebarDatabasePinned: (connectionId: string, dbName: string, pinned: boolean) => void;
|
||||
setTableSortPreference: (connectionId: string, dbName: string, sortBy: 'name' | 'frequency') => void;
|
||||
replaceTreeNodeChildren: (key: React.Key, children: TreeNode[] | undefined) => void;
|
||||
loadDatabases: (node: any) => Promise<void>;
|
||||
@@ -96,6 +102,7 @@ export const useSidebarV2ActionHandlers = ({
|
||||
connections,
|
||||
connectionTags,
|
||||
pinnedSidebarTables,
|
||||
pinnedSidebarDatabases,
|
||||
loadingNodesRef,
|
||||
treeDataRef,
|
||||
findTreeNodeByKeyRef,
|
||||
@@ -121,6 +128,7 @@ export const useSidebarV2ActionHandlers = ({
|
||||
removeConnectionTag,
|
||||
moveConnectionToTag,
|
||||
setSidebarTablePinned,
|
||||
setSidebarDatabasePinned,
|
||||
setTableSortPreference,
|
||||
replaceTreeNodeChildren,
|
||||
loadDatabases,
|
||||
@@ -265,6 +273,46 @@ export const useSidebarV2ActionHandlers = ({
|
||||
message.success(shouldPin ? t('sidebar.message.table_pinned') : t('sidebar.message.table_unpinned'));
|
||||
};
|
||||
|
||||
const toggleSidebarDatabasePinned = (node: any, pinned?: boolean) => {
|
||||
const conn = node?.dataRef || {};
|
||||
const connectionId = String(conn.id || '').trim();
|
||||
const dbName = String(conn.dbName || node?.title || '').trim();
|
||||
if (!connectionId || !dbName) return;
|
||||
const currentlyPinned = isSidebarDatabasePinned(
|
||||
pinnedSidebarDatabases,
|
||||
connectionId,
|
||||
dbName,
|
||||
);
|
||||
const shouldPin = pinned ?? !currentlyPinned;
|
||||
const nextPinnedSidebarDatabases = updateSidebarDatabasePinKeys(
|
||||
pinnedSidebarDatabases,
|
||||
connectionId,
|
||||
dbName,
|
||||
shouldPin,
|
||||
);
|
||||
setSidebarDatabasePinned(connectionId, dbName, shouldPin);
|
||||
|
||||
const connectionNode = findTreeNodeByKeyRef.current(treeDataRef.current, connectionId);
|
||||
if (connectionNode?.children?.length) {
|
||||
replaceTreeNodeChildren(
|
||||
connectionId,
|
||||
buildV2SidebarDatabaseSectionedChildren(
|
||||
connectionId,
|
||||
applySidebarDatabasePinning(
|
||||
connectionNode.children,
|
||||
{ connectionId, pinnedSidebarDatabases: nextPinnedSidebarDatabases },
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const latestConnection = connections.find((candidate) => candidate.id === connectionId);
|
||||
void loadDatabases({ key: connectionId, dataRef: latestConnection || conn });
|
||||
}
|
||||
message.success(shouldPin
|
||||
? t('sidebar.message.database_pinned')
|
||||
: t('sidebar.message.database_unpinned'));
|
||||
};
|
||||
|
||||
const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => {
|
||||
const groupData = node.dataRef;
|
||||
setTableSortPreference(groupData.id, groupData.dbName, sortBy);
|
||||
@@ -322,6 +370,10 @@ export const useSidebarV2ActionHandlers = ({
|
||||
|
||||
const handleV2DatabaseContextMenuAction = (node: any, action: V2DatabaseContextMenuActionKey) => {
|
||||
switch (action) {
|
||||
case 'pin-database':
|
||||
case 'unpin-database':
|
||||
toggleSidebarDatabasePinned(node, action === 'pin-database');
|
||||
return;
|
||||
case 'copy-database-name':
|
||||
void handleCopyDatabaseName(node);
|
||||
return;
|
||||
|
||||
@@ -22,7 +22,12 @@ import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities';
|
||||
import { resolveConnectionHostSummary } from '../../utils/tabDisplay';
|
||||
import { resolveConnectionIconType } from '../../utils/connectionVisual';
|
||||
import { formatSidebarRowCount } from './sidebarHelpers';
|
||||
import { isSidebarTablePinned, type SidebarTreeNode as TreeNode, type V2RailConnectionGroup } from '../sidebarV2Utils';
|
||||
import {
|
||||
isSidebarDatabasePinned,
|
||||
isSidebarTablePinned,
|
||||
type SidebarTreeNode as TreeNode,
|
||||
type V2RailConnectionGroup,
|
||||
} from '../sidebarV2Utils';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction } from '../tableDataDangerActions';
|
||||
import {
|
||||
SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT,
|
||||
@@ -55,6 +60,7 @@ type SidebarV2ContextMenuOptions = {
|
||||
};
|
||||
tableSortPreference: Record<string, any>;
|
||||
pinnedSidebarTables: any[];
|
||||
pinnedSidebarDatabases: string[];
|
||||
getConnectionNodeForAction: (conn: SavedConnection) => TreeNode;
|
||||
buildRuntimeConfig: (conn: any, overrideDatabase?: string, clearDatabase?: boolean) => any;
|
||||
extractObjectName: (fullName: string) => string;
|
||||
@@ -115,6 +121,7 @@ export const useSidebarV2ContextMenu = ({
|
||||
v2TreeMetrics,
|
||||
tableSortPreference,
|
||||
pinnedSidebarTables,
|
||||
pinnedSidebarDatabases,
|
||||
getConnectionNodeForAction,
|
||||
buildRuntimeConfig,
|
||||
extractObjectName,
|
||||
@@ -331,9 +338,15 @@ export const useSidebarV2ContextMenu = ({
|
||||
const renderV2DatabaseContextMenu = (node: any) => {
|
||||
const dialect = getMetadataDialect(node.dataRef as SavedConnection);
|
||||
const capabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config);
|
||||
const dbName = String(node.dataRef?.dbName || node.title || '');
|
||||
const isPinned = isSidebarDatabasePinned(
|
||||
pinnedSidebarDatabases,
|
||||
String(node.dataRef?.id || ''),
|
||||
dbName,
|
||||
);
|
||||
return (
|
||||
<V2DatabaseContextMenuView
|
||||
dbName={String(node.dataRef?.dbName || node.title || '')}
|
||||
dbName={dbName}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
dialect={dialect}
|
||||
supportsSchemaActions={isPostgresSchemaDialect(dialect)}
|
||||
@@ -341,6 +354,7 @@ export const useSidebarV2ContextMenu = ({
|
||||
supportsStarRocksActions={dialect === 'starrocks'}
|
||||
supportsRenameDatabase={capabilities.supportsRenameDatabase}
|
||||
supportsDropDatabase={capabilities.supportsDropDatabase}
|
||||
isPinned={isPinned}
|
||||
onAction={(action) => {
|
||||
setContextMenu(null);
|
||||
if (action === 'schema-visibility') {
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('sidebarV2 command search performance helpers', () => {
|
||||
})).toEqual(['conn-1-db-a', 'conn-1-db-b']);
|
||||
});
|
||||
|
||||
it('keeps large table groups loaded on collapse and only unloads reloadable database trees', () => {
|
||||
it('keeps database and table children loaded on collapse', () => {
|
||||
const tableChildren = Array.from({ length: 180 }, (_, index) => ({
|
||||
key: `table-${index}`,
|
||||
title: `table_${index}`,
|
||||
@@ -139,6 +139,10 @@ describe('sidebarV2 command search performance helpers', () => {
|
||||
expect(shouldClearSidebarNodeChildrenOnCollapse({
|
||||
type: 'database',
|
||||
children: tableChildren,
|
||||
})).toBe(false);
|
||||
expect(shouldClearSidebarNodeChildrenOnCollapse({
|
||||
type: 'connection',
|
||||
children: tableChildren,
|
||||
})).toBe(true);
|
||||
expect(shouldClearSidebarNodeChildrenOnCollapse({
|
||||
type: 'table',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Key, ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
resolveConnectionTagChildOrder,
|
||||
buildSidebarDatabasePinKey,
|
||||
buildSidebarRootConnectionToken,
|
||||
buildSidebarRootTagToken,
|
||||
buildSidebarTablePinKey,
|
||||
@@ -36,6 +37,7 @@ export type SidebarTreeNodeType =
|
||||
| 'sequence'
|
||||
| 'package'
|
||||
| 'object-group'
|
||||
| 'v2-database-section'
|
||||
| 'v2-table-section'
|
||||
| 'queries-folder'
|
||||
| 'saved-query'
|
||||
@@ -188,6 +190,95 @@ export const isSidebarTablePinned = (
|
||||
return !!key && pinnedKeys.includes(key);
|
||||
};
|
||||
|
||||
export const isSidebarDatabasePinned = (
|
||||
pinnedKeys: string[],
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
): boolean => {
|
||||
const key = buildSidebarDatabasePinKey(connectionId, dbName);
|
||||
return !!key && pinnedKeys.includes(key);
|
||||
};
|
||||
|
||||
export const applySidebarDatabasePinning = (
|
||||
nodes: SidebarTreeNode[],
|
||||
options: {
|
||||
connectionId: string;
|
||||
pinnedSidebarDatabases?: string[];
|
||||
},
|
||||
): SidebarTreeNode[] => {
|
||||
const pinnedNodes: Array<{ node: SidebarTreeNode; order: number; index: number }> = [];
|
||||
const regularNodes: Array<{ node: SidebarTreeNode; order: number; index: number }> = [];
|
||||
const pinnedKeys = options.pinnedSidebarDatabases || [];
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
if (node.type === 'v2-database-section') {
|
||||
return;
|
||||
}
|
||||
if (node.type !== 'database') {
|
||||
regularNodes.push({ node, order: index, index });
|
||||
return;
|
||||
}
|
||||
const dbName = String(node.dataRef?.dbName || node.title || '').trim();
|
||||
const pinned = isSidebarDatabasePinned(pinnedKeys, options.connectionId, dbName);
|
||||
const currentlyPinned = node.dataRef?.pinnedSidebarDatabase === true;
|
||||
const savedOrder = Number(node.dataRef?.sidebarDatabaseOrder);
|
||||
const order = Number.isSafeInteger(savedOrder) && savedOrder >= 0 ? savedOrder : index;
|
||||
let nextNode = node;
|
||||
if (currentlyPinned !== pinned || node.dataRef?.sidebarDatabaseOrder !== order) {
|
||||
const dataRef = { ...(node.dataRef || {}) };
|
||||
dataRef.sidebarDatabaseOrder = order;
|
||||
if (pinned) {
|
||||
dataRef.pinnedSidebarDatabase = true;
|
||||
} else {
|
||||
delete dataRef.pinnedSidebarDatabase;
|
||||
}
|
||||
nextNode = { ...node, dataRef };
|
||||
}
|
||||
(pinned ? pinnedNodes : regularNodes).push({ node: nextNode, order, index });
|
||||
});
|
||||
|
||||
const byOriginalOrder = (
|
||||
left: { order: number; index: number },
|
||||
right: { order: number; index: number },
|
||||
) => left.order - right.order || left.index - right.index;
|
||||
|
||||
return [
|
||||
...pinnedNodes.sort(byOriginalOrder),
|
||||
...regularNodes.sort(byOriginalOrder),
|
||||
].map(({ node }) => node);
|
||||
};
|
||||
|
||||
export const buildV2SidebarDatabaseSectionedChildren = (
|
||||
parentKey: string,
|
||||
databaseNodes: SidebarTreeNode[],
|
||||
translate: SidebarV2Translate = translateSidebarV2Current,
|
||||
): SidebarTreeNode[] => {
|
||||
const nodesWithoutSections = databaseNodes.some((node) => node.type === 'v2-database-section')
|
||||
? databaseNodes.filter((node) => node.type !== 'v2-database-section')
|
||||
: databaseNodes;
|
||||
const pinnedDatabases = nodesWithoutSections.filter((node) => node?.dataRef?.pinnedSidebarDatabase);
|
||||
if (pinnedDatabases.length === 0) return nodesWithoutSections;
|
||||
|
||||
const regularDatabases = nodesWithoutSections.filter((node) => !node?.dataRef?.pinnedSidebarDatabase);
|
||||
const buildSectionNode = (kind: 'pinned' | 'all', title: string): SidebarTreeNode => ({
|
||||
title,
|
||||
key: `${parentKey}-v2-${kind}-databases-section`,
|
||||
type: 'v2-database-section',
|
||||
isLeaf: true,
|
||||
selectable: false,
|
||||
dataRef: {
|
||||
sectionKind: kind,
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
buildSectionNode('pinned', translate('table_overview.section.pinned')),
|
||||
...pinnedDatabases,
|
||||
buildSectionNode('all', translate('table_overview.section.all')),
|
||||
...regularDatabases,
|
||||
];
|
||||
};
|
||||
|
||||
export const sortSidebarTableEntries = <T extends SidebarTableEntryForSort>(
|
||||
entries: T[],
|
||||
options: {
|
||||
@@ -1132,7 +1223,7 @@ export const shouldClearSidebarNodeChildrenOnCollapse = (
|
||||
if (!node || node.isLeaf === true || !node.children?.length) {
|
||||
return false;
|
||||
}
|
||||
if (node.type !== 'connection' && node.type !== 'database') {
|
||||
if (node.type !== 'connection') {
|
||||
return false;
|
||||
}
|
||||
return collectSidebarSubtreeKeys(node).length >= SIDEBAR_COLLAPSE_UNLOAD_SUBTREE_LIMIT;
|
||||
|
||||
@@ -3591,3 +3591,39 @@ describe('store persistence hot path', () => {
|
||||
expect(Object.prototype.hasOwnProperty.call(scrubbedProjection, 'connections')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebar database pin persistence', () => {
|
||||
let storage: MemoryStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new MemoryStorage();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('persists database pins by connection and database name', async () => {
|
||||
const { buildSidebarDatabasePinKey, updateSidebarDatabasePinKeys, useStore } = await importStore();
|
||||
const pinKey = buildSidebarDatabasePinKey(' conn-1 ', ' analytics ');
|
||||
|
||||
expect(pinKey).toBe(JSON.stringify(['conn-1', 'analytics']));
|
||||
expect(updateSidebarDatabasePinKeys([], 'conn-1', 'analytics', true)).toEqual([pinKey]);
|
||||
expect(updateSidebarDatabasePinKeys([pinKey], 'conn-1', 'analytics', true)).toEqual([pinKey]);
|
||||
|
||||
useStore.getState().setSidebarDatabasePinned('conn-1', 'analytics', true);
|
||||
expect(useStore.getState().pinnedSidebarDatabases).toEqual([pinKey]);
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.pinnedSidebarDatabases).toEqual([pinKey]);
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
expect(reloaded.useStore.getState().pinnedSidebarDatabases).toEqual([pinKey]);
|
||||
|
||||
reloaded.useStore.getState().setSidebarDatabasePinned('conn-1', 'analytics', false);
|
||||
expect(reloaded.useStore.getState().pinnedSidebarDatabases).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1796,6 +1796,7 @@ interface AppState {
|
||||
tableHiddenColumns: Record<string, string[]>;
|
||||
enableHiddenColumnMemory: boolean;
|
||||
pinnedSidebarTables: string[];
|
||||
pinnedSidebarDatabases: string[];
|
||||
windowBounds: { width: number; height: number; x: number; y: number } | null;
|
||||
windowState: "normal" | "fullscreen" | "maximized";
|
||||
sidebarWidth: number;
|
||||
@@ -1996,6 +1997,11 @@ interface AppState {
|
||||
schemaName: string | undefined,
|
||||
pinned: boolean,
|
||||
) => void;
|
||||
setSidebarDatabasePinned: (
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
pinned: boolean,
|
||||
) => void;
|
||||
setTableColumnOrder: (
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
@@ -3225,6 +3231,31 @@ export const buildSidebarTablePinKey = (
|
||||
return parts[0] && parts[1] && parts[3] ? JSON.stringify(parts) : "";
|
||||
};
|
||||
|
||||
export const buildSidebarDatabasePinKey = (
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
): string => {
|
||||
const parts = [toTrimmedString(connectionId), toTrimmedString(dbName)];
|
||||
return parts[0] && parts[1] ? JSON.stringify(parts) : "";
|
||||
};
|
||||
|
||||
export const updateSidebarDatabasePinKeys = (
|
||||
pinnedKeys: unknown,
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
pinned: boolean,
|
||||
): string[] => {
|
||||
const current = new Set(sanitizePinnedSidebarTables(pinnedKeys));
|
||||
const key = buildSidebarDatabasePinKey(connectionId, dbName);
|
||||
if (!key) return Array.from(current);
|
||||
if (pinned) {
|
||||
current.add(key);
|
||||
} else {
|
||||
current.delete(key);
|
||||
}
|
||||
return Array.from(current);
|
||||
};
|
||||
|
||||
// --- AI 会话文件持久化辅助函数 ---
|
||||
|
||||
/** 每个 session 独立防抖定时器(2秒) */
|
||||
@@ -3397,6 +3428,7 @@ const PERSISTED_STATE_DEPENDENCY_KEYS = [
|
||||
"tableHiddenColumns",
|
||||
"enableHiddenColumnMemory",
|
||||
"pinnedSidebarTables",
|
||||
"pinnedSidebarDatabases",
|
||||
"windowBounds",
|
||||
"windowState",
|
||||
"sidebarWidth",
|
||||
@@ -3459,6 +3491,7 @@ const buildPersistedStateProjection = (
|
||||
tableHiddenColumns: state.tableHiddenColumns,
|
||||
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
|
||||
pinnedSidebarTables: state.pinnedSidebarTables,
|
||||
pinnedSidebarDatabases: state.pinnedSidebarDatabases,
|
||||
windowBounds: state.windowBounds,
|
||||
windowState: state.windowState,
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
@@ -3583,6 +3616,7 @@ export const useStore = create<AppState>()(
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: true,
|
||||
pinnedSidebarTables: [],
|
||||
pinnedSidebarDatabases: [],
|
||||
windowBounds: null,
|
||||
windowState: "normal" as const,
|
||||
sidebarWidth: 330,
|
||||
@@ -5251,6 +5285,16 @@ export const useStore = create<AppState>()(
|
||||
return { pinnedSidebarTables: Array.from(current) };
|
||||
}),
|
||||
|
||||
setSidebarDatabasePinned: (connectionId, dbName, pinned) =>
|
||||
set((state) => ({
|
||||
pinnedSidebarDatabases: updateSidebarDatabasePinKeys(
|
||||
state.pinnedSidebarDatabases,
|
||||
connectionId,
|
||||
dbName,
|
||||
pinned,
|
||||
),
|
||||
})),
|
||||
|
||||
setTableColumnOrder: (connectionId, dbName, tableName, order) =>
|
||||
set((state) => {
|
||||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||||
@@ -5862,6 +5906,9 @@ export const useStore = create<AppState>()(
|
||||
nextState.pinnedSidebarTables = sanitizePinnedSidebarTables(
|
||||
state.pinnedSidebarTables,
|
||||
);
|
||||
nextState.pinnedSidebarDatabases = sanitizePinnedSidebarTables(
|
||||
state.pinnedSidebarDatabases,
|
||||
);
|
||||
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
|
||||
nextState.windowState = sanitizeWindowState(state.windowState);
|
||||
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
|
||||
@@ -5961,6 +6008,9 @@ export const useStore = create<AppState>()(
|
||||
pinnedSidebarTables: sanitizePinnedSidebarTables(
|
||||
state.pinnedSidebarTables,
|
||||
),
|
||||
pinnedSidebarDatabases: sanitizePinnedSidebarTables(
|
||||
state.pinnedSidebarDatabases,
|
||||
),
|
||||
windowBounds: sanitizeWindowBounds(state.windowBounds),
|
||||
windowState: sanitizeWindowState(state.windowState),
|
||||
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
|
||||
|
||||
@@ -7128,9 +7128,11 @@
|
||||
"sidebar.action.locate_current_tab": "Aktuellen Tab lokalisieren",
|
||||
"sidebar.action.locate_current_table": "Aktuell geöffnete Tabelle lokalisieren",
|
||||
"sidebar.action.new_group": "Neue Gruppe",
|
||||
"sidebar.action.pin_database": "Datenbank anheften",
|
||||
"sidebar.action.pin_table": "Tabelle anheften",
|
||||
"sidebar.action.sql_tools": "SQL-Werkzeuge",
|
||||
"sidebar.action.select_all": "Alle auswählen",
|
||||
"sidebar.action.unpin_database": "Datenbankfixierung aufheben",
|
||||
"sidebar.action.unpin_table": "Anheften aufheben",
|
||||
"sidebar.active_connection.actions": "Verbindungsaktionen",
|
||||
"sidebar.active_connection.current_host_database": "Aktueller Host und Datenbank",
|
||||
@@ -7378,7 +7380,9 @@
|
||||
"sidebar.message.database_export_success": "{{database}} wurde exportiert.",
|
||||
"sidebar.message.database_name_required": "Datenbankname ist erforderlich.",
|
||||
"sidebar.message.database_name_unchanged": "Datenbankname ist unverändert.",
|
||||
"sidebar.message.database_pinned": "Datenbank angeheftet",
|
||||
"sidebar.message.database_renamed": "Datenbank umbenannt.",
|
||||
"sidebar.message.database_unpinned": "Datenbankfixierung aufgehoben",
|
||||
"sidebar.message.delete_connection_backend_unavailable": "Verbindungen können in diesem Build nicht gelöscht werden.",
|
||||
"sidebar.message.delete_connection_failed": "Verbindung konnte nicht gelöscht werden.",
|
||||
"sidebar.message.delete_databases_failed": "Datenbank {{database}} konnte nicht gelöscht werden: {{error}}",
|
||||
|
||||
@@ -7128,9 +7128,11 @@
|
||||
"sidebar.action.locate_current_tab": "Locate current tab",
|
||||
"sidebar.action.locate_current_table": "Locate current open table",
|
||||
"sidebar.action.new_group": "New group",
|
||||
"sidebar.action.pin_database": "Pin database",
|
||||
"sidebar.action.pin_table": "Pin table",
|
||||
"sidebar.action.sql_tools": "SQL tools",
|
||||
"sidebar.action.select_all": "Select all",
|
||||
"sidebar.action.unpin_database": "Unpin database",
|
||||
"sidebar.action.unpin_table": "Unpin table",
|
||||
"sidebar.active_connection.actions": "Connection actions",
|
||||
"sidebar.active_connection.current_host_database": "Current host and database",
|
||||
@@ -7378,7 +7380,9 @@
|
||||
"sidebar.message.database_export_success": "Exported {{database}}.",
|
||||
"sidebar.message.database_name_required": "Database name is required.",
|
||||
"sidebar.message.database_name_unchanged": "Database name is unchanged.",
|
||||
"sidebar.message.database_pinned": "Database pinned",
|
||||
"sidebar.message.database_renamed": "Database renamed.",
|
||||
"sidebar.message.database_unpinned": "Database unpinned",
|
||||
"sidebar.message.delete_connection_backend_unavailable": "Delete connection is not available in this build.",
|
||||
"sidebar.message.delete_connection_failed": "Failed to delete connection.",
|
||||
"sidebar.message.delete_databases_failed": "Failed to delete database {{database}}: {{error}}",
|
||||
|
||||
@@ -7128,9 +7128,11 @@
|
||||
"sidebar.action.locate_current_tab": "現在のタブを特定",
|
||||
"sidebar.action.locate_current_table": "現在開いているテーブルを特定",
|
||||
"sidebar.action.new_group": "新しいグループ",
|
||||
"sidebar.action.pin_database": "データベースを固定",
|
||||
"sidebar.action.pin_table": "テーブルを固定",
|
||||
"sidebar.action.sql_tools": "SQL ツール",
|
||||
"sidebar.action.select_all": "すべて選択",
|
||||
"sidebar.action.unpin_database": "データベースの固定を解除",
|
||||
"sidebar.action.unpin_table": "テーブル固定を解除",
|
||||
"sidebar.active_connection.actions": "接続操作",
|
||||
"sidebar.active_connection.current_host_database": "現在の Host とデータベース",
|
||||
@@ -7378,7 +7380,9 @@
|
||||
"sidebar.message.database_export_success": "{{database}} をエクスポートしました。",
|
||||
"sidebar.message.database_name_required": "データベース名を入力してください。",
|
||||
"sidebar.message.database_name_unchanged": "データベース名は変更されていません。",
|
||||
"sidebar.message.database_pinned": "データベースを固定しました",
|
||||
"sidebar.message.database_renamed": "データベース名を変更しました。",
|
||||
"sidebar.message.database_unpinned": "データベースの固定を解除しました",
|
||||
"sidebar.message.delete_connection_backend_unavailable": "このビルドでは接続の削除を利用できません。",
|
||||
"sidebar.message.delete_connection_failed": "接続の削除に失敗しました。",
|
||||
"sidebar.message.delete_databases_failed": "データベース {{database}} の削除に失敗しました: {{error}}",
|
||||
|
||||
@@ -7128,9 +7128,11 @@
|
||||
"sidebar.action.locate_current_tab": "Найти текущую вкладку",
|
||||
"sidebar.action.locate_current_table": "Найти текущую открытую таблицу",
|
||||
"sidebar.action.new_group": "Новая группа",
|
||||
"sidebar.action.pin_database": "Закрепить базу данных",
|
||||
"sidebar.action.pin_table": "Закрепить таблицу",
|
||||
"sidebar.action.sql_tools": "Инструменты SQL",
|
||||
"sidebar.action.select_all": "Выбрать все",
|
||||
"sidebar.action.unpin_database": "Открепить базу данных",
|
||||
"sidebar.action.unpin_table": "Открепить таблицу",
|
||||
"sidebar.active_connection.actions": "Действия с подключением",
|
||||
"sidebar.active_connection.current_host_database": "Текущий Host и база данных",
|
||||
@@ -7378,7 +7380,9 @@
|
||||
"sidebar.message.database_export_success": "{{database}} экспортирована.",
|
||||
"sidebar.message.database_name_required": "Введите имя базы данных.",
|
||||
"sidebar.message.database_name_unchanged": "Имя базы данных не изменилось.",
|
||||
"sidebar.message.database_pinned": "База данных закреплена",
|
||||
"sidebar.message.database_renamed": "База данных переименована.",
|
||||
"sidebar.message.database_unpinned": "База данных откреплена",
|
||||
"sidebar.message.delete_connection_backend_unavailable": "Удаление подключений недоступно в этой сборке.",
|
||||
"sidebar.message.delete_connection_failed": "Не удалось удалить подключение.",
|
||||
"sidebar.message.delete_databases_failed": "Не удалось удалить базу данных {{database}}: {{error}}",
|
||||
|
||||
@@ -7128,9 +7128,11 @@
|
||||
"sidebar.action.locate_current_tab": "定位当前标签页",
|
||||
"sidebar.action.locate_current_table": "定位当前打开表",
|
||||
"sidebar.action.new_group": "新建分组",
|
||||
"sidebar.action.pin_database": "置顶数据库",
|
||||
"sidebar.action.pin_table": "置顶表",
|
||||
"sidebar.action.sql_tools": "SQL 工具",
|
||||
"sidebar.action.select_all": "全选",
|
||||
"sidebar.action.unpin_database": "取消置顶数据库",
|
||||
"sidebar.action.unpin_table": "取消置顶",
|
||||
"sidebar.active_connection.actions": "连接操作",
|
||||
"sidebar.active_connection.current_host_database": "当前 Host 与数据库",
|
||||
@@ -7378,7 +7380,9 @@
|
||||
"sidebar.message.database_export_success": "已导出 {{database}}。",
|
||||
"sidebar.message.database_name_required": "请输入数据库名称。",
|
||||
"sidebar.message.database_name_unchanged": "数据库名称未变化。",
|
||||
"sidebar.message.database_pinned": "已置顶数据库",
|
||||
"sidebar.message.database_renamed": "数据库已重命名。",
|
||||
"sidebar.message.database_unpinned": "已取消置顶数据库",
|
||||
"sidebar.message.delete_connection_backend_unavailable": "删除连接后端不可用。",
|
||||
"sidebar.message.delete_connection_failed": "删除连接失败。",
|
||||
"sidebar.message.delete_databases_failed": "删除库 {{database}} 失败:{{error}}",
|
||||
|
||||
@@ -7128,9 +7128,11 @@
|
||||
"sidebar.action.locate_current_tab": "定位目前分頁",
|
||||
"sidebar.action.locate_current_table": "定位目前開啟的表",
|
||||
"sidebar.action.new_group": "新增群組",
|
||||
"sidebar.action.pin_database": "置頂資料庫",
|
||||
"sidebar.action.pin_table": "置頂資料表",
|
||||
"sidebar.action.sql_tools": "SQL 工具",
|
||||
"sidebar.action.select_all": "全選",
|
||||
"sidebar.action.unpin_database": "取消置頂資料庫",
|
||||
"sidebar.action.unpin_table": "取消置頂",
|
||||
"sidebar.active_connection.actions": "連線操作",
|
||||
"sidebar.active_connection.current_host_database": "目前 Host 與資料庫",
|
||||
@@ -7378,7 +7380,9 @@
|
||||
"sidebar.message.database_export_success": "已匯出 {{database}}。",
|
||||
"sidebar.message.database_name_required": "請輸入資料庫名稱。",
|
||||
"sidebar.message.database_name_unchanged": "資料庫名稱未變更。",
|
||||
"sidebar.message.database_pinned": "已置頂資料庫",
|
||||
"sidebar.message.database_renamed": "資料庫已重新命名。",
|
||||
"sidebar.message.database_unpinned": "已取消置頂資料庫",
|
||||
"sidebar.message.delete_connection_backend_unavailable": "刪除連線後端不可用。",
|
||||
"sidebar.message.delete_connection_failed": "刪除連線失敗。",
|
||||
"sidebar.message.delete_databases_failed": "刪除資料庫 {{database}} 失敗:{{error}}",
|
||||
|
||||
Reference in New Issue
Block a user