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