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:
Syngnat
2026-07-31 23:06:01 +08:00
committed by GitHub
19 changed files with 581 additions and 13 deletions

View File

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

View File

@@ -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;

View File

@@ -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') }] : []),

View File

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

View File

@@ -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,

View File

@@ -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(),

View File

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

View File

@@ -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;

View File

@@ -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') {

View File

@@ -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',

View File

@@ -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;

View File

@@ -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([]);
});
});

View File

@@ -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),

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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}}",