From d36244019cf7758d2fce89269d524e7684be6314 Mon Sep 17 00:00:00 2001
From: AutumnNazi <104422820+AutumnNazi@users.noreply.github.com>
Date: Fri, 31 Jul 2026 20:50:53 +0800
Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E7=BD=AE=E9=A1=B6=E4=B8=8E=E5=88=86?=
=?UTF-8?q?=E5=8C=BA=E5=B1=95=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 支持在 V2 数据库右键菜单中置顶和取消置顶
- 持久化数据库置顶状态并按置顶与全部分区展示
- 保留数据库原始顺序及已加载的展开子树
- 修复数据库折叠后再次展开触发重新连接的问题
- 补充多语言文案和侧边栏回归测试
---
.../Sidebar.locate-toolbar.test.tsx | 180 ++++++++++++++++++
frontend/src/components/Sidebar.tsx | 18 +-
.../src/components/V2TableContextMenu.tsx | 5 +
.../components/sidebar/SidebarTreeTitle.tsx | 16 +-
...SidebarTreeLoaders.nacos-services.test.tsx | 3 +
.../useSidebarTreeLoaders.partitions.test.tsx | 75 ++++++++
.../sidebar/useSidebarTreeLoaders.tsx | 18 +-
.../sidebar/useSidebarV2ActionHandlers.tsx | 52 +++++
.../sidebar/useSidebarV2ContextMenu.tsx | 18 +-
.../sidebarV2Utils.command-search.test.ts | 6 +-
frontend/src/components/sidebarV2Utils.ts | 93 ++++++++-
frontend/src/store.test.ts | 36 ++++
frontend/src/store.ts | 50 +++++
shared/i18n/de-DE.json | 4 +
shared/i18n/en-US.json | 4 +
shared/i18n/ja-JP.json | 4 +
shared/i18n/ru-RU.json | 4 +
shared/i18n/zh-CN.json | 4 +
shared/i18n/zh-TW.json | 4 +
19 files changed, 581 insertions(+), 13 deletions(-)
diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx
index e2b4b254..db357b08 100644
--- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx
+++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx
@@ -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(
+ ,
+ );
+ const pinnedMarkup = renderToStaticMarkup(
+ ,
+ );
+
+ 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 = (');
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 86657854..359f0605 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -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;
diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx
index 13aa114c..aa096ab9 100644
--- a/frontend/src/components/V2TableContextMenu.tsx
+++ b/frontend/src/components/V2TableContextMenu.tsx
@@ -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<{
{renderItems([
{ action: 'copy-database-name', icon:
, title: t('sidebar.menu.copy_database_name'), kbd: primaryShortcut('C', shortcutPlatform), featured: true },
+ { action: isPinned ? 'unpin-database' : 'pin-database', icon:
, 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:
, title: t('sidebar.menu.create_table'), kbd: primaryShortcut('N', shortcutPlatform), featured: true },
...(supportsSchemaActions ? [{ action: 'new-schema', icon:
, title: t('sidebar.v2_database_menu.new_schema') }] : []),
...(supportsSchemaVisibility ? [{ action: 'schema-visibility', icon:
, title: t('sidebar.schema_visibility.menu.manage') }] : []),
diff --git a/frontend/src/components/sidebar/SidebarTreeTitle.tsx b/frontend/src/components/sidebar/SidebarTreeTitle.tsx
index 1888610b..7b803589 100644
--- a/frontend/src/components/sidebar/SidebarTreeTitle.tsx
+++ b/frontend/src/components/sidebar/SidebarTreeTitle.tsx
@@ -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 (
@@ -268,7 +274,7 @@ export const renderSidebarV2TreeTitle = ({
return (
<>
{wrappedTitleNode}
- {tablePinIndicator}
+ {pinIndicator}
>
);
};
diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.nacos-services.test.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.nacos-services.test.tsx
index 971367b5..2e4cd006 100644
--- a/frontend/src/components/sidebar/useSidebarTreeLoaders.nacos-services.test.tsx
+++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.nacos-services.test.tsx
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
tableSortPreference: {} as Record
,
tableAccessCount: {} as Record,
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,
diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx
index 06686756..910dbfa0 100644
--- a/frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx
+++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.partitions.test.tsx
@@ -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,
tableAccessCount: {} as Record,
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 | undefined;
+ const Harness = () => {
+ loaders = useSidebarTreeLoaders({
+ savedQueries: [],
+ tableSortPreference: {},
+ tableAccessCount: {},
+ pinnedSidebarTables: [],
+ pinnedSidebarDatabases: [],
+ isV2Ui: true,
+ loadingNodesRef: { current: new Set() },
+ 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();
+ });
+ 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() },
setConnectionStates: vi.fn(),
diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx
index ac9f8954..f4281f5a 100644
--- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx
+++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx
@@ -52,7 +52,9 @@ import {
supportsDatabaseSequences,
} from './sidebarMetadataLoaders';
import {
+ applySidebarDatabasePinning,
buildSidebarTableChildrenForUi,
+ buildV2SidebarDatabaseSectionedChildren,
isSidebarTablePinned,
sortSidebarTableEntries,
type SidebarConnectionState,
@@ -157,6 +159,7 @@ type UseSidebarTreeLoadersOptions = {
tableSortPreference: Record;
tableAccessCount: Record;
pinnedSidebarTables: any[];
+ pinnedSidebarDatabases: string[];
isV2Ui: boolean;
loadingNodesRef: React.MutableRefObject>;
setConnectionStates: React.Dispatch>>;
@@ -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: ,
@@ -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 {
diff --git a/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx b/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx
index 4f253191..ef737e9c 100644
--- a/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx
+++ b/frontend/src/components/sidebar/useSidebarV2ActionHandlers.tsx
@@ -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>;
treeDataRef: MutableRefObject;
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;
@@ -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;
diff --git a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx
index cbf76d20..3d5d6e2d 100644
--- a/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx
+++ b/frontend/src/components/sidebar/useSidebarV2ContextMenu.tsx
@@ -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;
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 (
{
setContextMenu(null);
if (action === 'schema-visibility') {
diff --git a/frontend/src/components/sidebarV2Utils.command-search.test.ts b/frontend/src/components/sidebarV2Utils.command-search.test.ts
index e17dc900..acae7dfe 100644
--- a/frontend/src/components/sidebarV2Utils.command-search.test.ts
+++ b/frontend/src/components/sidebarV2Utils.command-search.test.ts
@@ -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',
diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts
index dc23b24b..fcb5d441 100644
--- a/frontend/src/components/sidebarV2Utils.ts
+++ b/frontend/src/components/sidebarV2Utils.ts
@@ -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 = (
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;
diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts
index b5e44aeb..90cc4e5a 100644
--- a/frontend/src/store.test.ts
+++ b/frontend/src/store.test.ts
@@ -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([]);
+ });
+});
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index 39189432..bd5e13c2 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -1796,6 +1796,7 @@ interface AppState {
tableHiddenColumns: Record;
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()(
tableHiddenColumns: {},
enableHiddenColumnMemory: true,
pinnedSidebarTables: [],
+ pinnedSidebarDatabases: [],
windowBounds: null,
windowState: "normal" as const,
sidebarWidth: 330,
@@ -5251,6 +5285,16 @@ export const useStore = create()(
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()(
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()(
pinnedSidebarTables: sanitizePinnedSidebarTables(
state.pinnedSidebarTables,
),
+ pinnedSidebarDatabases: sanitizePinnedSidebarTables(
+ state.pinnedSidebarDatabases,
+ ),
windowBounds: sanitizeWindowBounds(state.windowBounds),
windowState: sanitizeWindowState(state.windowState),
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json
index e351664d..45ff4ef4 100644
--- a/shared/i18n/de-DE.json
+++ b/shared/i18n/de-DE.json
@@ -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}}",
diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json
index 79517870..23e02e60 100644
--- a/shared/i18n/en-US.json
+++ b/shared/i18n/en-US.json
@@ -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}}",
diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json
index 1a0a6f45..06e56ab4 100644
--- a/shared/i18n/ja-JP.json
+++ b/shared/i18n/ja-JP.json
@@ -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}}",
diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json
index af96cc8c..a2ba1883 100644
--- a/shared/i18n/ru-RU.json
+++ b/shared/i18n/ru-RU.json
@@ -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}}",
diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json
index 36d4ed5e..00e31339 100644
--- a/shared/i18n/zh-CN.json
+++ b/shared/i18n/zh-CN.json
@@ -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}}",
diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json
index d6fd7175..87412019 100644
--- a/shared/i18n/zh-TW.json
+++ b/shared/i18n/zh-TW.json
@@ -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}}",