♻️ refactor(sidebar): 拆分动作与搜索逻辑

This commit is contained in:
Syngnat
2026-06-19 18:46:08 +08:00
parent 13705f9098
commit 6179c3fbd9
8 changed files with 3053 additions and 2225 deletions

View File

@@ -72,6 +72,11 @@ const readSidebarSource = () => [
readSourceFile('./sidebar/SidebarEntityModals.tsx'),
readSourceFile('./sidebar/SidebarTreeTitle.tsx'),
readSourceFile('./sidebar/useSidebarV2ContextMenu.tsx'),
readSourceFile('./sidebar/useSidebarObjectActions.tsx'),
readSourceFile('./sidebar/useSidebarSearchModel.tsx'),
readSourceFile('./sidebar/useSidebarV2ActionHandlers.tsx'),
readSourceFile('./sidebar/useSidebarCommandSearchRunner.ts'),
readSourceFile('./sidebar/useSidebarTitleRender.tsx'),
readSourceFile('./sidebarV2Utils.ts'),
].join('\n');
const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx');
@@ -1319,17 +1324,17 @@ describe('Sidebar locate toolbar', () => {
],
}];
expect(filterV2ExplorerTreeByKind(tree, 'all')[0].children?.map((node) => node.key)).toEqual([
expect(filterV2ExplorerTreeByKind(tree, 'all')[0].children?.map((node: { key: string }) => node.key)).toEqual([
'conn-main-queries',
'conn-main-tables',
'conn-main-views',
'conn-main-routines',
'conn-main-events',
]);
expect(filterV2ExplorerTreeByKind(tree, 'tables')[0].children?.map((node) => node.key)).toEqual(['conn-main-tables']);
expect(filterV2ExplorerTreeByKind(tree, 'views')[0].children?.map((node) => node.key)).toEqual(['conn-main-views']);
expect(filterV2ExplorerTreeByKind(tree, 'routines')[0].children?.map((node) => node.key)).toEqual(['conn-main-routines']);
expect(filterV2ExplorerTreeByKind(tree, 'events')[0].children?.map((node) => node.key)).toEqual(['conn-main-events']);
expect(filterV2ExplorerTreeByKind(tree, 'tables')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-tables']);
expect(filterV2ExplorerTreeByKind(tree, 'views')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-views']);
expect(filterV2ExplorerTreeByKind(tree, 'routines')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-routines']);
expect(filterV2ExplorerTreeByKind(tree, 'events')[0].children?.map((node: { key: string }) => node.key)).toEqual(['conn-main-events']);
});
it('hides external SQL roots from v2 object kind filters', () => {
@@ -1362,11 +1367,11 @@ describe('Sidebar locate toolbar', () => {
},
];
expect(filterV2ExplorerTreeByKind(tree, 'all').map((node) => node.key)).toEqual([
expect(filterV2ExplorerTreeByKind(tree, 'all').map((node: { key: string }) => node.key)).toEqual([
'conn-main',
'external-sql-root',
]);
expect(filterV2ExplorerTreeByKind(tree, 'tables').map((node) => node.key)).toEqual(['conn-main']);
expect(filterV2ExplorerTreeByKind(tree, 'tables').map((node: { key: string }) => node.key)).toEqual(['conn-main']);
});
it('adds rename to the saved query context menu', () => {
@@ -2042,25 +2047,27 @@ describe('Sidebar locate toolbar', () => {
it('routes v2 database context menu shell copy through i18n wrappers in Sidebar', () => {
const source = readSidebarSource();
const createSchemaSource = source.slice(
source.indexOf('const openCreateSchemaModal = (node: any) => {'),
source.indexOf('const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => {'),
const objectActionsSource = readSourceFile('./sidebar/useSidebarObjectActions.tsx');
const v2ActionHandlersSource = readSourceFile('./sidebar/useSidebarV2ActionHandlers.tsx');
const createSchemaSource = objectActionsSource.slice(
objectActionsSource.indexOf('const openCreateSchemaModal = (node: any) => {'),
objectActionsSource.indexOf('const openRenameSchemaModal = (node: any) => {'),
);
const runSqlSource = source.slice(
source.indexOf('const handleRunSQLFile = async (node: any) => {'),
source.indexOf('const handleOpenSQLFileFromToolbar = async () => {'),
);
const databaseShellSource = source.slice(
source.indexOf('const handleRenameDatabase = async () => {'),
source.indexOf('const handleRenameTable = async () => {'),
const databaseShellSource = objectActionsSource.slice(
objectActionsSource.indexOf('const handleRenameDatabase = async () => {'),
objectActionsSource.indexOf('const handleRenameTable = async () => {'),
);
const databaseActionSource = source.slice(
source.indexOf('const closeDatabaseNode = (node: any) => {'),
source.indexOf('const refreshConnectionNode = (node: any) => {'),
const databaseActionSource = v2ActionHandlersSource.slice(
v2ActionHandlersSource.indexOf('const closeDatabaseNode = (node: any) => {'),
v2ActionHandlersSource.indexOf('const openDatabaseQuery = (node: any) => {'),
);
const starRocksSource = source.slice(
source.indexOf('const openCreateStarRocksMaterializedView = (node: any) => {'),
source.indexOf('const openCreateStarRocksRollup = (node: any) => {'),
const starRocksSource = objectActionsSource.slice(
objectActionsSource.indexOf('const openCreateStarRocksMaterializedView = (node: any) => {'),
objectActionsSource.indexOf('const openCreateStarRocksRollup = (node: any) => {'),
);
expect(createSchemaSource).toContain("message.warning(t('sidebar.message.schema_create_unsupported'))");
@@ -2354,9 +2361,9 @@ describe('Sidebar locate toolbar', () => {
const externalSqlFileMenuStart = externalSqlDirectoryMenuEnd;
const externalSqlFileMenuEnd = legacyMenuSource.indexOf('return [];', externalSqlFileMenuStart);
const externalSqlFileMenuSource = legacyMenuSource.slice(externalSqlFileMenuStart, externalSqlFileMenuEnd);
const titleRenderStart = source.indexOf('const titleRender = (node: any) => {');
const titleRenderEnd = source.indexOf('const handleDrop = (info: any) => {', titleRenderStart);
const titleRenderSource = source.slice(titleRenderStart, titleRenderEnd);
const titleRenderSource = readSourceFile('./sidebar/useSidebarTitleRender.tsx');
const titleRenderStart = titleRenderSource.indexOf('export const useSidebarTitleRender =');
const titleRenderEnd = titleRenderSource.length;
[
loadTablesStart,
@@ -2621,11 +2628,11 @@ describe('Sidebar locate toolbar', () => {
expect(objectGroupTitleSource).toContain(catalogLookup);
});
const titleRenderStart = sidebarSource.indexOf('const titleRender = (node: any) => {');
const titleRenderEnd = sidebarSource.indexOf('const handleDrop = (info: any) => {', titleRenderStart);
const titleRenderSource = readSourceFile('./sidebar/useSidebarTitleRender.tsx');
const titleRenderStart = titleRenderSource.indexOf('export const useSidebarTitleRender =');
const titleRenderEnd = titleRenderSource.length;
expect(titleRenderStart).toBeGreaterThanOrEqual(0);
expect(titleRenderEnd).toBeGreaterThan(titleRenderStart);
const titleRenderSource = sidebarSource.slice(titleRenderStart, titleRenderEnd);
expect(titleRenderSource).toContain("} else if (node.type === 'object-group') {");
expect(titleRenderSource).toContain('const objectGroupTitle = resolveV2ObjectGroupTitle(node);');
expect(titleRenderSource).toContain('hoverTitle = objectGroupTitle;');

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import {
formatSidebarRowCount,
} from './Sidebar';
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const source = readFileSync(new URL('./sidebar/useSidebarV2ActionHandlers.tsx', import.meta.url), 'utf8');
const toggleSidebarTablePinnedSource = source.slice(
source.indexOf('const toggleSidebarTablePinned = (node: any, pinned?: boolean) => {'),
source.indexOf("const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => {"),

View File

@@ -0,0 +1,168 @@
import { useCallback, type MutableRefObject, type Dispatch, type SetStateAction } from 'react';
import type { SavedConnection } from '../../types';
import { resolveSidebarNodeConnectionId, shouldRunV2CommandSearchEnter, type SidebarTreeNode as TreeNode, type V2CommandSearchItem } from '../sidebarV2Utils';
type UseSidebarCommandSearchRunnerArgs = {
activeContext: any;
activeTab: any;
addTab: (tab: any) => void;
closeV2CommandSearch: () => void;
commandSearchFlatItems: V2CommandSearchItem[];
connectionIds: string[];
findTreeNodeByKeyRef: MutableRefObject<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>;
locateObjectInSidebar: (detail: unknown) => Promise<void>;
loadDatabases: (node: any) => Promise<void>;
mergeExpandedTreeKeys: (requiredKeys: React.Key[]) => void;
onDoubleClick: (event: any, node: any) => void;
scrollSidebarTreeToKey: (key: React.Key) => void;
selectedNodesRef: MutableRefObject<any[]>;
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
setSelectedKeys: Dispatch<SetStateAction<React.Key[]>>;
setV2CommandActiveIndex: Dispatch<SetStateAction<number>>;
treeDataRef: MutableRefObject<TreeNode[]>;
v2CommandActiveIndex: number;
};
export const useSidebarCommandSearchRunner = ({
activeContext,
activeTab,
addTab,
closeV2CommandSearch,
commandSearchFlatItems,
connectionIds,
findTreeNodeByKeyRef,
locateObjectInSidebar,
loadDatabases,
mergeExpandedTreeKeys,
onDoubleClick,
scrollSidebarTreeToKey,
selectedNodesRef,
setActiveContext,
setSelectedKeys,
setV2CommandActiveIndex,
treeDataRef,
v2CommandActiveIndex,
}: UseSidebarCommandSearchRunnerArgs) => {
const selectConnectionFromRail = useCallback((conn: SavedConnection) => {
const key = conn.id;
const connectionNode = findTreeNodeByKeyRef.current(treeDataRef.current, key);
setSelectedKeys([key]);
selectedNodesRef.current = connectionNode ? [connectionNode] : [];
setActiveContext({ connectionId: key, dbName: '' });
mergeExpandedTreeKeys([key]);
const targetNode = connectionNode || {
key,
dataRef: conn,
type: 'connection',
};
void loadDatabases(targetNode);
}, [findTreeNodeByKeyRef, loadDatabases, mergeExpandedTreeKeys, selectedNodesRef, setActiveContext, setSelectedKeys, treeDataRef]);
const runCommandSearchItem = useCallback((item?: V2CommandSearchItem) => {
if (!item) return;
closeV2CommandSearch();
if (item.kind === 'action') {
item.onRun();
return;
}
if (item.kind === 'recent') {
addTab({
id: `query-${Date.now()}`,
title: '最近查询',
type: 'query',
connectionId: item.connectionId || activeContext?.connectionId || activeTab?.connectionId || '',
dbName: item.dbName || activeContext?.dbName || activeTab?.dbName || '',
query: item.sql,
});
return;
}
const node = item.node;
const dataRef = node.dataRef || {};
if (node.type === 'connection') {
selectConnectionFromRail(dataRef as SavedConnection);
return;
}
if (node.type === 'database') {
setActiveContext({ connectionId: resolveSidebarNodeConnectionId(node, connectionIds) || dataRef.id, dbName: dataRef.dbName });
mergeExpandedTreeKeys([dataRef.id, node.key]);
setSelectedKeys([node.key]);
selectedNodesRef.current = [node];
scrollSidebarTreeToKey(node.key);
return;
}
if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') {
void locateObjectInSidebar({
tabId: String(node.key || ''),
connectionId: dataRef.id,
dbName: dataRef.dbName,
tableName: dataRef.tableName || dataRef.viewName,
schemaName: dataRef.schemaName,
objectGroup: node.type === 'table' ? 'tables' : (node.type === 'materialized-view' ? 'materializedViews' : 'views'),
});
onDoubleClick(null, node);
return;
}
if (node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
setSelectedKeys([node.key]);
selectedNodesRef.current = [node];
scrollSidebarTreeToKey(node.key);
onDoubleClick(null, node);
}
}, [
activeContext,
activeTab,
addTab,
closeV2CommandSearch,
connectionIds,
locateObjectInSidebar,
mergeExpandedTreeKeys,
onDoubleClick,
scrollSidebarTreeToKey,
selectConnectionFromRail,
selectedNodesRef,
setActiveContext,
setSelectedKeys,
]);
const handleV2CommandSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setV2CommandActiveIndex((prev) => {
if (commandSearchFlatItems.length === 0) return 0;
return Math.min(prev + 1, commandSearchFlatItems.length - 1);
});
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setV2CommandActiveIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (event.key === 'Enter') {
if (!shouldRunV2CommandSearchEnter({
key: event.key,
isComposing: event.nativeEvent.isComposing,
keyCode: event.nativeEvent.keyCode,
activeItemCount: commandSearchFlatItems.length,
})) {
return;
}
event.preventDefault();
runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeV2CommandSearch();
}
};
return {
selectConnectionFromRail,
runCommandSearchItem,
handleV2CommandSearchKeyDown,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
import React, { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { Checkbox } from 'antd';
import {
BarsOutlined,
CheckOutlined,
ClockCircleOutlined,
CloudOutlined,
CodeOutlined,
DatabaseOutlined,
EyeOutlined,
FilterOutlined,
PlusOutlined,
RobotOutlined,
TableOutlined,
TagOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useStore } from '../../store';
import type { SavedConnection } from '../../types';
import { getCurrentLanguage, t } from '../../i18n';
import { resolveShortcutDisplay } from '../../utils/shortcuts';
import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../../utils/tabDisplay';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../../utils/connectionVisual';
import { getDbIcon } from '../DatabaseIcons';
import {
isV2SidebarObjectNode,
parseV2CommandSearchQuery,
type V2ExplorerFilter,
} from './sidebarHelpers';
import type { SearchScope } from '../sidebarCoreUtils';
import {
V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE,
estimateV2TreeHorizontalScrollWidth,
filterV2CommandSearchTreeItems,
filterV2ExplorerTreeByKind,
resolveSidebarNodeConnectionId,
resolveV2ActiveConnectionId,
type SidebarTreeNode as TreeNode,
type V2CommandSearchItem,
} from '../sidebarV2Utils';
const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; labelKey: string }> = [
{ value: 'smart', labelKey: 'sidebar.command_search.scope.smart' },
{ value: 'object', labelKey: 'sidebar.command_search.scope.object' },
{ value: 'database', labelKey: 'sidebar.command_search.scope.database' },
{ value: 'host', labelKey: 'sidebar.command_search.scope.host' },
{ value: 'tag', labelKey: 'sidebar.command_search.scope.tag' },
];
const SEARCH_SCOPE_LABEL_KEY_MAP: Record<SearchScope, string> = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => {
acc[option.value] = option.labelKey;
return acc;
}, {} as Record<SearchScope, string>);
const SEARCH_SCOPE_ICON_MAP: Record<SearchScope, React.ReactNode> = {
smart: <ThunderboltOutlined />,
object: <TableOutlined />,
database: <DatabaseOutlined />,
host: <CloudOutlined />,
tag: <TagOutlined />,
};
type SidebarSearchModelArgs = {
searchScopes: SearchScope[];
setSearchScopes: Dispatch<SetStateAction<SearchScope[]>>;
setSearchValue: Dispatch<SetStateAction<string>>;
deferredSearchValue: string;
deferredV2CommandSearchValue: string;
v2CommandSearchValue: string;
setV2CommandActiveIndex: Dispatch<SetStateAction<number>>;
v2ExplorerFilter: V2ExplorerFilter;
treeData: TreeNode[];
treeViewportWidth: number;
treeHeight: number;
isV2Ui: boolean;
connections: SavedConnection[];
connectionIds: string[];
selectedKeys: React.Key[];
selectedNodesRef: MutableRefObject<any[]>;
activeContext: any;
activeTab: any;
sqlLogs: any[];
shortcutOptions: any;
activeShortcutPlatform: any;
overlayTheme: {
sectionBorder: string;
mutedText: string;
titleText: string;
shellBg: string;
divider: string;
};
darkMode: boolean;
onCreateConnection?: () => void;
onToggleAI?: () => void;
onToggleLogPanel?: () => void;
setAIPanelVisible: (visible: boolean) => void;
extractObjectName: (fullName: string) => string;
};
export const useSidebarSearchModel = ({
searchScopes,
setSearchScopes,
setSearchValue,
deferredSearchValue,
deferredV2CommandSearchValue,
v2CommandSearchValue,
setV2CommandActiveIndex,
v2ExplorerFilter,
treeData,
treeViewportWidth,
treeHeight,
isV2Ui,
connections,
connectionIds,
selectedKeys,
selectedNodesRef,
activeContext,
activeTab,
sqlLogs,
shortcutOptions,
activeShortcutPlatform,
overlayTheme,
darkMode,
onCreateConnection,
onToggleAI,
onToggleLogPanel,
setAIPanelVisible,
extractObjectName,
}: SidebarSearchModelArgs) => {
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
setSearchValue(value);
};
const toggleSearchScope = (scope: SearchScope) => {
setSearchScopes((prev) => {
if (scope === 'smart') {
return ['smart'];
}
const withoutSmart = prev.filter((item) => item !== 'smart');
if (withoutSmart.includes(scope)) {
const next = withoutSmart.filter((item) => item !== scope);
return next.length > 0 ? next : ['smart'];
}
return [...withoutSmart, scope];
});
};
const setSearchScopeChecked = (scope: SearchScope, checked: boolean) => {
if (scope === 'smart') {
if (checked) {
setSearchScopes(['smart']);
} else if (searchScopes.length === 1 && searchScopes[0] === 'smart') {
setSearchScopes(['smart']);
} else {
setSearchScopes((prev) => {
const next = prev.filter((item) => item !== 'smart');
return next.length > 0 ? next : ['smart'];
});
}
return;
}
if (checked) {
setSearchScopes((prev) => {
const withoutSmart = prev.filter((item) => item !== 'smart');
if (withoutSmart.includes(scope)) {
return withoutSmart;
}
return [...withoutSmart, scope];
});
} else {
setSearchScopes((prev) => {
const next = prev.filter((item) => item !== scope && item !== 'smart');
return next.length > 0 ? next : ['smart'];
});
}
};
const currentLanguage = getCurrentLanguage();
const searchScopeSummary = useMemo(() => {
if (searchScopes.includes('smart')) {
return t('sidebar.command_search.scope.summary_smart');
}
return searchScopes.map((scope) => t(SEARCH_SCOPE_LABEL_KEY_MAP[scope])).join(' + ');
}, [searchScopes, currentLanguage]);
const searchScopePopoverContent = useMemo(() => {
const smartSelected = searchScopes.includes('smart');
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
const borderColor = overlayTheme.sectionBorder.replace('1px solid ', '');
const mutedTextColor = overlayTheme.mutedText;
const titleColor = overlayTheme.titleText;
const panelBg = overlayTheme.shellBg;
const smartBg = smartSelected
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
const smartBorder = smartSelected
? (darkMode ? 'rgba(255,214,102,0.42)' : 'rgba(245,176,65,0.34)')
: borderColor;
const getOptionCardStyle = (checked: boolean) => ({
display: 'flex',
alignItems: 'center' as const,
justifyContent: 'space-between' as const,
gap: 12,
padding: '10px 12px',
borderRadius: 12,
border: `1px solid ${checked ? (darkMode ? 'rgba(118,169,250,0.44)' : 'rgba(24,144,255,0.32)') : borderColor}`,
background: checked
? (darkMode ? 'rgba(64,124,255,0.18)' : 'rgba(24,144,255,0.08)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)'),
transition: 'all 120ms ease',
});
return (
<div style={{ minWidth: 280, display: 'flex', flexDirection: 'column', background: panelBg, padding: 14, gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: mutedTextColor, textTransform: 'uppercase' }}>{t('sidebar.command_search.scope.title')}</div>
<div style={{ marginTop: 4, fontSize: 13, lineHeight: 1.5, color: mutedTextColor }}>{t('sidebar.command_search.scope.description')}</div>
</div>
<div style={{ width: 32, height: 32, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<FilterOutlined />
</div>
</div>
<label style={{ display: 'block', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderRadius: 14, border: `1px solid ${smartBorder}`, background: smartBg, boxShadow: smartSelected ? (darkMode ? '0 10px 24px rgba(0,0,0,0.24)' : '0 10px 24px rgba(245,176,65,0.14)') : 'none' }}>
<Checkbox
checked={smartSelected}
onChange={(e) => setSearchScopeChecked('smart', e.target.checked)}
/>
<div style={{ width: 30, height: 30, borderRadius: 10, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.3)', color: darkMode ? '#ffd666' : '#ad6800', flexShrink: 0 }}>
{SEARCH_SCOPE_ICON_MAP.smart}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14, fontWeight: 700, color: titleColor }}>{t('sidebar.command_search.scope.smart')}</span>
<span style={{ padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700, color: darkMode ? '#ffe58f' : '#ad6800', background: darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(255,214,102,0.35)' }}>{t('sidebar.command_search.scope.recommended')}</span>
</div>
<div style={{ marginTop: 3, fontSize: 12, lineHeight: 1.5, color: mutedTextColor }}>{t('sidebar.command_search.scope.smart_help')}</div>
</div>
</div>
</label>
<div style={{ height: 1, background: overlayTheme.divider, opacity: 0.9 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}>{t('sidebar.command_search.scope.manual_title')}</div>
<div style={{ fontSize: 12, color: mutedTextColor }}>{t('sidebar.command_search.scope.multi_select')}</div>
</div>
<div style={{ display: 'grid', gap: 8 }}>
{scopedOptions.map((option) => {
const checked = searchScopes.includes(option.value);
return (
<label key={option.value} style={{ display: 'block', cursor: 'pointer' }}>
<div style={getOptionCardStyle(checked)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<Checkbox
checked={checked}
onChange={(e) => setSearchScopeChecked(option.value, e.target.checked)}
/>
<div style={{ width: 28, height: 28, borderRadius: 9, display: 'grid', placeItems: 'center', background: checked ? (darkMode ? 'rgba(118,169,250,0.2)' : 'rgba(24,144,255,0.12)') : (darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(17,24,39,0.06)'), color: checked ? (darkMode ? '#91caff' : '#1677ff') : mutedTextColor, flexShrink: 0 }}>
{SEARCH_SCOPE_ICON_MAP[option.value]}
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: titleColor, whiteSpace: 'nowrap' }}>{t(option.labelKey)}</span>
</div>
<div style={{ width: 18, display: 'flex', justifyContent: 'center', color: checked ? (darkMode ? '#91caff' : '#1677ff') : 'transparent', flexShrink: 0 }}>
<CheckOutlined />
</div>
</div>
</label>
);
})}
</div>
<div style={{ padding: '10px 12px', borderRadius: 12, background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(17,24,39,0.04)', color: mutedTextColor, fontSize: 12, lineHeight: 1.6 }}>
{t('sidebar.command_search.scope.manual_help')}
</div>
</div>
);
}, [darkMode, overlayTheme, searchScopes, currentLanguage]);
const getConnectionHostSearchText = (node: TreeNode): string => {
if (node.type !== 'connection') return '';
const config = node.dataRef?.config || {};
return resolveConnectionHostTokens(config).join(' ');
};
const getConnectionNameSearchText = (node: TreeNode): string => {
if (node.type !== 'connection') return '';
const name = node.dataRef?.name ?? node.title;
return String(name || '').toLowerCase();
};
const matchByScopes = (node: TreeNode, keyword: string, scopes: SearchScope[]): boolean => {
const title = String(node.title || '').toLowerCase();
if (scopes.includes('database') && node.type === 'database' && title.includes(keyword)) {
return true;
}
if (scopes.includes('tag') && node.type === 'tag' && title.includes(keyword)) {
return true;
}
if (scopes.includes('host') && node.type === 'connection' && getConnectionHostSearchText(node).includes(keyword)) {
return true;
}
if (scopes.includes('object') && (isV2SidebarObjectNode(node) || node.type === 'object-group') && title.includes(keyword)) {
return true;
}
if (node.type === 'external-sql-root' || node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
const pathText = String(node?.dataRef?.path || '').toLowerCase();
return title.includes(keyword) || pathText.includes(keyword);
}
return false;
};
const loop = (data: TreeNode[], keyword: string): TreeNode[] => {
const isSmartMode = searchScopes.includes('smart');
const result: TreeNode[] = [];
data.forEach((item) => {
const titleMatch = String(item.title || '').toLowerCase().includes(keyword);
const smartMatch = item.type === 'connection'
? getConnectionNameSearchText(item).includes(keyword) || getConnectionHostSearchText(item).includes(keyword)
: titleMatch;
const scopedMatch = matchByScopes(item, keyword, searchScopes);
const selfMatch = isSmartMode ? smartMatch : scopedMatch;
const filteredChildren = item.children ? loop(item.children, keyword) : [];
if (selfMatch) {
const shouldKeepFullSubtree = isSmartMode
|| item.type === 'connection'
|| item.type === 'database'
|| item.type === 'tag'
|| item.type === 'external-sql-root'
|| item.type === 'external-sql-directory'
|| item.type === 'external-sql-folder';
if (item.children && shouldKeepFullSubtree) {
result.push(item);
} else if (item.children && filteredChildren.length > 0) {
result.push({ ...item, children: filteredChildren });
} else {
result.push(item);
}
return;
}
if (filteredChildren.length > 0) {
result.push({ ...item, children: filteredChildren });
}
});
return result;
};
const displayTreeData = useMemo(() => {
const keyword = deferredSearchValue.trim().toLowerCase();
if (!keyword) return treeData;
return loop(treeData, keyword);
}, [deferredSearchValue, searchScopes, treeData]);
const commandSearchTreeItems = useMemo(() => {
const result: V2CommandSearchItem[] = [];
const visit = (nodes: TreeNode[]) => {
nodes.forEach((node) => {
const dataRef = node.dataRef || {};
if (node.type === 'connection') {
const conn = dataRef as SavedConnection;
result.push({
key: `node-${node.key}`,
kind: 'node',
title: String(node.title || conn.name || t('connection.unnamed')),
meta: resolveConnectionHostSummary(conn.config) || conn.config?.type || t('connection.sidebar.menu.section'),
icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 16),
node,
});
} else if (node.type === 'database') {
const conn = connections.find((item) => item.id === dataRef.id);
result.push({
key: `node-${node.key}`,
kind: 'node',
title: String(node.title || dataRef.dbName || t('database.unnamed')),
meta: conn?.name || dataRef.id || t('database.label'),
icon: <DatabaseOutlined />,
node,
});
} else if (
node.type === 'table'
|| node.type === 'view'
|| node.type === 'materialized-view'
|| node.type === 'db-trigger'
|| node.type === 'db-event'
|| node.type === 'routine'
) {
const conn = connections.find((item) => item.id === dataRef.id);
const objectName = String(dataRef.tableName || dataRef.viewName || dataRef.triggerName || dataRef.eventName || dataRef.routineName || node.title || '').trim();
const displayName = String(node.title || extractObjectName(objectName) || objectName).trim();
result.push({
key: `node-${node.key}`,
kind: 'node',
title: displayName,
meta: [conn?.name || dataRef.id, dataRef.dbName].filter(Boolean).join(' · '),
icon: node.type === 'table'
? <TableOutlined />
: (node.type === 'db-event' ? <ClockCircleOutlined /> : (node.type === 'routine' ? <CodeOutlined /> : <EyeOutlined />)),
node,
});
}
if (node.children) visit(node.children);
});
};
visit(treeData);
return result;
}, [connections, treeData]);
const commandSearchRecentItems = useMemo<V2CommandSearchItem[]>(() => {
return sqlLogs.slice(0, 5).map((log) => ({
key: `recent-${log.id}`,
kind: 'recent',
title: log.sql.replace(/\s+/g, ' ').trim() || 'SQL 记录',
meta: `${new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · ${log.duration}ms${log.dbName ? ` · ${log.dbName}` : ''}`,
icon: <ClockCircleOutlined />,
sql: log.sql,
dbName: log.dbName,
}));
}, [sqlLogs]);
const commandSearchActionItems = useMemo<V2CommandSearchItem[]>(() => [
{
key: 'action-new-query',
kind: 'action',
title: t('query.new'),
meta: '打开一个新的 SQL 编辑页',
shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform),
icon: <PlusOutlined />,
onRun: () => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab')),
},
{
key: 'action-new-connection',
kind: 'action',
title: '新建数据源',
meta: '创建数据库、运行时或其他数据源连接',
shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform),
icon: <ThunderboltOutlined />,
onRun: () => onCreateConnection?.(),
},
{
key: 'action-open-ai',
kind: 'action',
title: '打开 AI 数据洞察',
meta: '让 AI 分析当前数据库上下文',
shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform),
icon: <RobotOutlined />,
onRun: () => onToggleAI?.(),
},
{
key: 'action-open-sql-log',
kind: 'action',
title: '查看 SQL 执行日志',
meta: '打开最近执行记录面板',
shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform),
icon: <BarsOutlined />,
onRun: () => onToggleLogPanel?.(),
},
], [activeShortcutPlatform, onCreateConnection, onToggleAI, onToggleLogPanel, shortcutOptions]);
const v2CommandSearchQuery = useMemo(
() => parseV2CommandSearchQuery(deferredV2CommandSearchValue),
[deferredV2CommandSearchValue],
);
const normalizedV2CommandSearchValue = v2CommandSearchQuery.normalizedKeyword;
const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';
const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';
const filteredCommandSearchTreeItems = useMemo(() => {
return filterV2CommandSearchTreeItems(commandSearchTreeItems, v2CommandSearchQuery);
}, [commandSearchTreeItems, v2CommandSearchQuery]);
const filteredCommandSearchActionItems = useMemo(() => {
if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return [];
if (!normalizedV2CommandSearchValue) return commandSearchActionItems;
return commandSearchActionItems.filter((item) => {
const haystack = `${item.title} ${item.meta}`.toLowerCase();
return haystack.includes(normalizedV2CommandSearchValue);
});
}, [commandSearchActionItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
const filteredCommandSearchRecentItems = useMemo(() => {
if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return [];
if (!normalizedV2CommandSearchValue) return commandSearchRecentItems;
return commandSearchRecentItems.filter((item) => {
const haystack = `${item.title} ${item.meta}`.toLowerCase();
return haystack.includes(normalizedV2CommandSearchValue);
});
}, [commandSearchRecentItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]);
const commandSearchAiItem = useMemo<V2CommandSearchItem[]>(() => {
if (!v2CommandSearchAiMode || !v2CommandSearchQuery.aiPrompt) return [];
return [{
key: 'action-ask-ai',
kind: 'action',
title: '让 AI 回答',
meta: v2CommandSearchQuery.aiPrompt,
shortcut: '↵',
icon: <RobotOutlined />,
onRun: () => {
const wasClosed = !useStore.getState().aiPanelVisible;
if (wasClosed) setAIPanelVisible(true);
window.setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', {
detail: { prompt: v2CommandSearchQuery.aiPrompt },
}));
}, wasClosed ? 350 : 0);
},
}];
}, [setAIPanelVisible, v2CommandSearchAiMode, v2CommandSearchQuery.aiPrompt]);
const commandSearchFlatItems = useMemo(
() => [
...commandSearchAiItem,
...filteredCommandSearchTreeItems,
...filteredCommandSearchActionItems,
...filteredCommandSearchRecentItems,
],
[commandSearchAiItem, filteredCommandSearchActionItems, filteredCommandSearchRecentItems, filteredCommandSearchTreeItems],
);
useEffect(() => {
setV2CommandActiveIndex(0);
}, [setV2CommandActiveIndex, v2CommandSearchValue, commandSearchFlatItems.length]);
const flattenConnectionNodes = useCallback((nodes: TreeNode[]): TreeNode[] => {
const result: TreeNode[] = [];
nodes.forEach((node) => {
if (node.type === 'connection') {
result.push(node);
}
if (node.children) {
result.push(...flattenConnectionNodes(node.children));
}
});
return result;
}, []);
const activeConnectionId = resolveV2ActiveConnectionId({
activeContextConnectionId: activeContext?.connectionId,
activeTabConnectionId: activeTab?.connectionId,
selectedKeys,
connectionIds,
fallbackConnectionId: selectedNodesRef.current
.map((node) => resolveSidebarNodeConnectionId(node, connectionIds))
.find(Boolean),
});
const activeConnection = connections.find((conn) => conn.id === activeConnectionId) || null;
const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || t('sidebar.active_connection.no_host_selected');
const activeDatabaseDisplayName = useMemo(() => {
if (activeContext && typeof activeContext === 'object' && 'dbName' in activeContext) {
return String(activeContext.dbName || '').trim();
}
return String(activeTab?.dbName || '').trim();
}, [activeContext, activeTab?.dbName]);
const activeConnectionTreeData = useMemo(() => {
const externalSQLNodes = displayTreeData.filter((node) => node.type === 'external-sql-root');
if (!activeConnection) return displayTreeData;
const activeConnectionNode = displayTreeData.find((node) => node.type === 'connection' && node.key === activeConnection.id);
if (activeConnectionNode) {
return [
...(activeConnectionNode.children && activeConnectionNode.children.length > 0 ? activeConnectionNode.children : []),
...externalSQLNodes,
];
}
const filterTree = (nodes: TreeNode[]): TreeNode[] => nodes.flatMap((node) => {
if (node.type === 'tag') {
return filterTree(node.children || []);
}
if (node.type === 'connection') {
if (node.key !== activeConnection.id) return [];
return node.children && node.children.length > 0 ? filterTree(node.children) : [];
}
return [{ ...node, children: node.children ? filterTree(node.children) : undefined }];
});
const filtered = filterTree(displayTreeData);
return [...filtered, ...externalSQLNodes];
}, [activeConnection, displayTreeData]);
const v2VisibleTreeData = useMemo(() => {
if (v2ExplorerFilter === 'all') {
return displayTreeData;
}
return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter);
}, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]);
const v2TreeHorizontalScrollWidth = useMemo(
() => estimateV2TreeHorizontalScrollWidth(v2VisibleTreeData, treeViewportWidth),
[treeViewportWidth, v2VisibleTreeData],
);
const effectiveTreeHeight = isV2Ui && v2TreeHorizontalScrollWidth
? Math.max(1, treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE)
: treeHeight;
const v2TreeMetrics = useMemo(() => {
const databaseTableCounts = new Map<React.Key, number>();
const objectGroupCounts = new Map<React.Key, number>();
let activeObjectCount = 0;
const visitAndCount = (node: TreeNode): number => {
const childCount = (node.children || []).reduce((total, child) => total + visitAndCount(child), 0);
const totalCount = (isV2SidebarObjectNode(node) ? 1 : 0) + childCount;
if (node.type === 'database') {
const tableCount = (node.children || []).reduce((total, child) => {
if (child.type === 'object-group' && child?.dataRef?.groupKey === 'tables') {
return total + (Array.isArray(child.children) ? child.children.filter((item) => item.type === 'table').length : 0);
}
if (child?.dataRef?.groupKey === 'schema' && Array.isArray(child.children)) {
return total + child.children.reduce((schemaTotal, schemaChild) => {
if (schemaChild.type === 'object-group' && schemaChild?.dataRef?.groupKey === 'tables') {
return schemaTotal + (Array.isArray(schemaChild.children) ? schemaChild.children.filter((item) => item.type === 'table').length : 0);
}
return schemaTotal;
}, 0);
}
return total;
}, 0);
databaseTableCounts.set(node.key, tableCount);
} else if (node.type === 'object-group') {
objectGroupCounts.set(node.key, childCount);
}
return totalCount;
};
activeObjectCount = v2VisibleTreeData.reduce((total, node) => total + visitAndCount(node), 0);
return {
activeObjectCount,
databaseTableCounts,
objectGroupCounts,
};
}, [v2VisibleTreeData]);
return {
onSearch,
toggleSearchScope,
setSearchScopeChecked,
searchScopeSummary,
searchScopePopoverContent,
displayTreeData,
commandSearchTreeItems,
commandSearchRecentItems,
commandSearchActionItems,
v2CommandSearchQuery,
normalizedV2CommandSearchValue,
v2CommandSearchObjectMode,
v2CommandSearchAiMode,
filteredCommandSearchTreeItems,
filteredCommandSearchActionItems,
filteredCommandSearchRecentItems,
commandSearchAiItem,
commandSearchFlatItems,
flattenConnectionNodes,
activeConnectionId,
activeConnection,
activeConnectionDisplayName,
activeDatabaseDisplayName,
activeConnectionTreeData,
v2VisibleTreeData,
v2TreeHorizontalScrollWidth,
effectiveTreeHeight,
v2TreeMetrics,
activeConnectionObjectCount: v2TreeMetrics.activeObjectCount,
};
};

View File

@@ -0,0 +1,168 @@
import React, { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { Badge, Button } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { SavedConnection } from '../../types';
import { t } from '../../i18n';
import JVMModeBadge from '../jvm/JVMModeBadge';
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../../utils/sidebarSqlDrag';
import {
resolveSidebarObjectDragText,
} from '../sidebarCoreUtils';
import {
shouldHideSchemaPrefix,
splitQualifiedName,
} from './sidebarMetadataLoaders';
import { resolveV2ObjectGroupTitle } from './sidebarHelpers';
type UseSidebarTitleRenderArgs = {
connectionStates: Record<string, 'success' | 'error'>;
isV2Ui: boolean;
renderV2TreeTitle: (node: any, hoverTitle: string, statusBadge: React.ReactNode) => React.ReactNode;
handleAddExternalSQLDirectory: (node: any) => Promise<void>;
snapshotTreeSelectionBeforeDrag: () => void;
restoreTreeSelectionAfterDrag: () => void;
treeDragSelectSuppressUntilRef: MutableRefObject<number>;
setIsTreeDragging: Dispatch<SetStateAction<boolean>>;
};
export const useSidebarTitleRender = ({
connectionStates,
isV2Ui,
renderV2TreeTitle,
handleAddExternalSQLDirectory,
snapshotTreeSelectionBeforeDrag,
restoreTreeSelectionAfterDrag,
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
}: UseSidebarTitleRenderArgs) => useCallback((node: any) => {
let status: 'success' | 'error' | 'default' = 'default';
if (node.type === 'connection' || node.type === 'database') {
if (connectionStates[node.key] === 'success') status = 'success';
else if (connectionStates[node.key] === 'error') status = 'error';
}
const statusBadge = node.type === 'connection' || node.type === 'database' ? (
isV2Ui
? <span className={`gn-v2-tree-status is-${status}`} aria-hidden="true" />
: <Badge status={status} style={{ marginLeft: 4, marginRight: 8 }} />
) : null;
const displayTitle = String(node.title ?? '');
const dragText = resolveSidebarObjectDragText(node);
let hoverTitle = displayTitle;
if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view' || node.type === 'db-event') {
const rawTableName = String(node?.dataRef?.tableName || node?.dataRef?.viewName || node?.dataRef?.eventName || '').trim();
const conn = node?.dataRef as SavedConnection | undefined;
if (rawTableName && shouldHideSchemaPrefix(conn)) {
if (splitQualifiedName(rawTableName).schemaName) {
hoverTitle = rawTableName;
}
}
} else if (node.type === 'object-group') {
const objectGroupTitle = resolveV2ObjectGroupTitle(node);
if (objectGroupTitle) {
hoverTitle = objectGroupTitle;
}
} else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
hoverTitle = String(node?.dataRef?.path || displayTitle);
}
if (node.type === 'jvm-mode') {
return (
<span
title={hoverTitle}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}
>
<JVMModeBadge
mode={String(node?.dataRef?.providerMode || displayTitle)}
label={displayTitle}
reason={String(node?.dataRef?.reason || '').trim() || undefined}
/>
</span>
);
}
if (node.type === 'external-sql-root') {
const externalSqlRootTitle = t('sidebar.external_sql.root');
const addSqlDirectoryLabel = t('sidebar.menu.add_sql_directory');
return (
<span
title={externalSqlRootTitle}
className="gn-v2-tree-external-root"
>
<span
className="gn-v2-tree-title"
data-node-type={node.type}
data-sidebar-node-key={String(node.key || '')}
data-sidebar-node-type={String(node.type || '')}
>
<span className="gn-v2-tree-label">
{statusBadge}
{externalSqlRootTitle}
</span>
</span>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
title={addSqlDirectoryLabel}
aria-label={addSqlDirectoryLabel}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleAddExternalSQLDirectory(node);
}}
className="gn-v2-tree-external-root-action"
/>
</span>
);
}
if (isV2Ui) {
return renderV2TreeTitle(node, hoverTitle, statusBadge);
}
if (dragText) {
return (
<span
title={hoverTitle}
draggable
onDragStart={(event) => {
snapshotTreeSelectionBeforeDrag();
treeDragSelectSuppressUntilRef.current = Date.now() + 600;
setIsTreeDragging(true);
event.stopPropagation();
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', dragText);
event.dataTransfer.setData(
SIDEBAR_SQL_EDITOR_DRAG_MIME,
encodeSidebarSqlEditorDragPayload({
text: dragText,
nodeType: node.type,
connectionId: String(node?.dataRef?.id || ''),
dbName: String(node?.dataRef?.dbName || ''),
}),
);
}}
onDragEnd={() => {
restoreTreeSelectionAfterDrag();
setIsTreeDragging(false);
}}
>
{statusBadge}{displayTitle}
</span>
);
}
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;
}, [
connectionStates,
handleAddExternalSQLDirectory,
isV2Ui,
renderV2TreeTitle,
restoreTreeSelectionAfterDrag,
setIsTreeDragging,
snapshotTreeSelectionBeforeDrag,
treeDragSelectSuppressUntilRef,
]);

View File

@@ -0,0 +1,538 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { message } from 'antd';
import type { FormInstance } from 'antd/es/form';
import Modal from '../common/ResizableDraggableModal';
import { t } from '../../i18n';
import type { SavedConnection } from '../../types';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../../utils/connectionVisual';
import { buildTableSelectQuery } from '../../utils/objectQueryTemplates';
import { DBReleaseConnection } from '../../../wailsjs/go/app/App';
import { getDbIcon } from '../DatabaseIcons';
import { getMetadataDialect } from './sidebarMetadataLoaders';
import {
type V2DatabaseContextMenuActionKey,
type V2ConnectionGroupContextMenuActionKey,
type V2ConnectionContextMenuActionKey,
type V2TableContextMenuActionKey,
type V2TableGroupContextMenuActionKey,
} from '../V2TableContextMenu';
import {
isSidebarTablePinned,
type SidebarTreeNode as TreeNode,
type V2RailConnectionGroup,
} from '../sidebarV2Utils';
type UseSidebarV2ActionHandlersArgs = {
connections: SavedConnection[];
connectionTags: Array<{ id: string; name: string; connectionIds: string[] }>;
pinnedSidebarTables: any[];
loadingNodesRef: MutableRefObject<Set<string>>;
treeDataRef: MutableRefObject<TreeNode[]>;
findTreeNodeByKeyRef: MutableRefObject<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>;
refreshV2TableContextMenuStatsRef: MutableRefObject<(node: any) => void>;
setConnectionStates: Dispatch<SetStateAction<Record<string, 'success' | 'error'>>>;
setExpandedKeys: Dispatch<SetStateAction<React.Key[]>>;
setLoadedKeys: Dispatch<SetStateAction<React.Key[]>>;
setTargetConnection: Dispatch<SetStateAction<any>>;
setIsCreateDbModalOpen: Dispatch<SetStateAction<boolean>>;
setRenameDbTarget: Dispatch<SetStateAction<any>>;
setIsRenameDbModalOpen: Dispatch<SetStateAction<boolean>>;
setRenameTableTarget: Dispatch<SetStateAction<any>>;
setIsRenameTableModalOpen: Dispatch<SetStateAction<boolean>>;
setRenameViewTarget: Dispatch<SetStateAction<any>>;
setIsCreateTagModalOpen: Dispatch<SetStateAction<boolean>>;
renameDbForm: FormInstance;
renameTableForm: FormInstance;
createTagForm: FormInstance;
addTab: (tab: any) => void;
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
closeTabsByConnection: (connectionId: string) => void;
removeConnection: (connectionId: string) => void;
removeConnectionTag: (tagId: string) => void;
moveConnectionToTag: (connectionId: string, tagId: string | null) => void;
setSidebarTablePinned: (connectionId: string, dbName: string, tableName: string, schemaName: 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>;
loadTables: (node: any) => Promise<void>;
getDatabaseNodeRef: (connRef: any, dbName: string) => any;
extractObjectName: (fullName: string) => string;
openDesign: (node: any, initialTab: string, readOnly?: boolean) => void;
openNewTableDesign: (node: any) => void;
onDoubleClick: (event: any, node: any) => void;
openMessagePublishModal: (node: any) => void;
openTableDdlInDesigner: (node: any) => void;
openTableInERView: (node: any) => void;
handleCopyTableName: (node: any) => Promise<void>;
handleCopyStructure: (node: any) => Promise<void>;
handleCopyTableAsInsert: (node: any) => Promise<void>;
openCreateStarRocksRollup: (node: any) => void;
handleExport: (node: any, options: { format: string; xlsxMaxRowsPerSheet?: number }) => Promise<void>;
openExportDialog: (node: any) => Promise<void>;
injectTablePromptToAI: (node: any, promptKind: 'explain' | 'query') => Promise<void>;
handleTableDataDangerAction: (node: any, action: 'truncate' | 'clear') => Promise<void>;
handleDeleteTable: (node: any) => void;
openCreateSchemaModal: (node: any) => void;
openCreateStarRocksMaterializedView: (node: any) => void;
openCreateStarRocksExternalCatalog: (node: any) => void;
handleExportDatabaseSQL: (node: any, includeData: boolean) => Promise<void>;
handleRunSQLFile: (node: any) => void;
handleDeleteDatabase: (node: any) => void;
onEditConnection?: (conn: SavedConnection) => void;
handleDuplicateConnection: (conn: SavedConnection) => Promise<void>;
buildConnectionRootQueryTabTitle: () => string;
buildConnectionRootRedisCommandTabTitle: (redisDbLabel?: string) => string;
buildConnectionRootRedisMonitorTabTitle: (redisDbLabel?: string) => string;
};
export const useSidebarV2ActionHandlers = ({
connections,
connectionTags,
pinnedSidebarTables,
loadingNodesRef,
treeDataRef,
findTreeNodeByKeyRef,
refreshV2TableContextMenuStatsRef,
setConnectionStates,
setExpandedKeys,
setLoadedKeys,
setTargetConnection,
setIsCreateDbModalOpen,
setRenameDbTarget,
setIsRenameDbModalOpen,
setRenameTableTarget,
setIsRenameTableModalOpen,
setRenameViewTarget,
setIsCreateTagModalOpen,
renameDbForm,
renameTableForm,
createTagForm,
addTab,
closeTabsByDatabase,
closeTabsByConnection,
removeConnection,
removeConnectionTag,
moveConnectionToTag,
setSidebarTablePinned,
setTableSortPreference,
replaceTreeNodeChildren,
loadDatabases,
loadTables,
getDatabaseNodeRef,
extractObjectName,
openDesign,
openNewTableDesign,
onDoubleClick,
openMessagePublishModal,
openTableDdlInDesigner,
openTableInERView,
handleCopyTableName,
handleCopyStructure,
handleCopyTableAsInsert,
openCreateStarRocksRollup,
handleExport,
openExportDialog,
injectTablePromptToAI,
handleTableDataDangerAction,
handleDeleteTable,
openCreateSchemaModal,
openCreateStarRocksMaterializedView,
openCreateStarRocksExternalCatalog,
handleExportDatabaseSQL,
handleRunSQLFile,
handleDeleteDatabase,
onEditConnection,
handleDuplicateConnection,
buildConnectionRootQueryTabTitle,
buildConnectionRootRedisCommandTabTitle,
buildConnectionRootRedisMonitorTabTitle,
}: UseSidebarV2ActionHandlersArgs) => {
const handleV2TableContextMenuAction = (node: any, action: V2TableContextMenuActionKey) => {
switch (action) {
case 'pin-table':
case 'unpin-table': {
toggleSidebarTablePinned(node, action === 'pin-table');
return;
}
case 'open-data':
case 'open-new-tab':
onDoubleClick(null, node);
return;
case 'design-table':
openDesign(node, 'columns', false);
return;
case 'new-query': {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
addTab({
id: `query-${Date.now()}`,
title: t('query.new'),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.dataRef.dbName,
query: queryTemplate,
});
return;
}
case 'publish-message':
openMessagePublishModal(node);
return;
case 'view-ddl':
openTableDdlInDesigner(node);
return;
case 'view-er':
openTableInERView(node);
return;
case 'copy-table-name':
void handleCopyTableName(node);
return;
case 'copy-structure':
void handleCopyStructure(node);
return;
case 'copy-insert':
void handleCopyTableAsInsert(node);
return;
case 'rename-table':
setRenameTableTarget(node);
renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) });
setIsRenameTableModalOpen(true);
return;
case 'new-rollup':
openCreateStarRocksRollup(node);
return;
case 'backup-table':
void handleExport(node, { format: 'sql' });
return;
case 'refresh-stats':
refreshV2TableContextMenuStatsRef.current(node);
return;
case 'export-data':
void openExportDialog(node);
return;
case 'ai-explain':
void injectTablePromptToAI(node, 'explain');
return;
case 'ai-generate-query':
void injectTablePromptToAI(node, 'query');
return;
case 'truncate-table':
void handleTableDataDangerAction(node, 'truncate');
return;
case 'drop-table':
handleDeleteTable(node);
return;
default:
return;
}
};
const toggleSidebarTablePinned = (node: any, pinned?: boolean) => {
const conn = node?.dataRef || {};
const tableName = String(conn.tableName || node?.title || '').trim();
const dbName = String(conn.dbName || '').trim();
if (!conn.id || !dbName || !tableName) return;
const currentlyPinned = isSidebarTablePinned(
pinnedSidebarTables,
String(conn.id || ''),
dbName,
tableName,
String(conn.schemaName || ''),
);
const shouldPin = pinned ?? !currentlyPinned;
setSidebarTablePinned(conn.id, dbName, tableName, conn.schemaName || '', shouldPin);
void loadTables(getDatabaseNodeRef(conn, dbName));
message.success(shouldPin ? t('sidebar.message.table_pinned') : t('sidebar.message.table_unpinned'));
};
const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => {
const groupData = node.dataRef;
setTableSortPreference(groupData.id, groupData.dbName, sortBy);
const dbNode = {
key: `${groupData.id}-${groupData.dbName}`,
dataRef: groupData,
};
loadTables(dbNode);
};
const handleV2TableGroupContextMenuAction = (node: any, action: V2TableGroupContextMenuActionKey) => {
switch (action) {
case 'new-table':
openNewTableDesign(node);
return;
case 'sort-by-name':
handleTableGroupSortAction(node, 'name');
return;
case 'sort-by-frequency':
handleTableGroupSortAction(node, 'frequency');
return;
default:
return;
}
};
const closeDatabaseNode = (node: any) => {
const dbConnId = String(node.dataRef?.id || '');
const dbName = String(node.dataRef?.dbName || node.title || '').trim();
loadingNodesRef.current.delete(`tables-${dbConnId}-${dbName}`);
setConnectionStates(prev => {
const next = { ...prev };
delete next[node.key];
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
replaceTreeNodeChildren(node.key, undefined);
if (dbConnId && dbName) {
closeTabsByDatabase(dbConnId, dbName);
}
message.success(t('sidebar.message.database_closed'));
};
const openDatabaseQuery = (node: any) => {
addTab({
id: `query-${Date.now()}`,
title: t('sidebar.tab.new_query_database', { database: node.title }),
type: 'query',
connectionId: node.dataRef.id,
dbName: node.title,
query: '',
});
};
const handleV2DatabaseContextMenuAction = (node: any, action: V2DatabaseContextMenuActionKey) => {
switch (action) {
case 'new-table':
openNewTableDesign(node);
return;
case 'new-schema':
openCreateSchemaModal(node);
return;
case 'new-materialized-view':
openCreateStarRocksMaterializedView(node);
return;
case 'new-external-catalog':
openCreateStarRocksExternalCatalog(node);
return;
case 'rename-db':
setRenameDbTarget(node);
renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' });
setIsRenameDbModalOpen(true);
return;
case 'refresh':
loadTables(node);
return;
case 'export-db-schema':
void handleExportDatabaseSQL(node, false);
return;
case 'backup-db-sql':
void handleExportDatabaseSQL(node, true);
return;
case 'disconnect-db':
closeDatabaseNode(node);
return;
case 'new-query':
openDatabaseQuery(node);
return;
case 'run-sql':
handleRunSQLFile(node);
return;
case 'drop-db':
handleDeleteDatabase(node);
return;
default:
return;
}
};
const refreshConnectionNode = (node: any) => {
const connKey = String(node?.key || node?.dataRef?.id || '');
if (!connKey) return;
setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
loadingNodesRef.current.delete(loadingKey);
}
});
loadDatabases(node);
};
const releaseConnectionResources = async (conn: SavedConnection | undefined) => {
if (!conn?.config) return;
const res = await DBReleaseConnection(buildRpcConnectionConfig(conn.config, { id: conn.id }) as any);
if (res && res.success === false) {
throw new Error(res.message || '释放连接失败');
}
};
const disconnectConnectionNode = async (node: any) => {
const connKey = String(node?.key || node?.dataRef?.id || '');
if (!connKey) return;
const conn = (connections.find((item) => item.id === connKey) || node?.dataRef) as SavedConnection | undefined;
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
loadingNodesRef.current.delete(loadingKey);
}
});
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === connKey || k.startsWith(`${connKey}-`)) {
delete next[k];
}
});
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
replaceTreeNodeChildren(connKey, undefined);
closeTabsByConnection(connKey);
try {
await releaseConnectionResources(conn);
} catch (error: any) {
message.warning(error?.message || '连接已从侧边栏断开,但后端连接释放失败');
}
message.success(t('connection.sidebar.disconnect.success'));
};
const deleteConnectionNode = (node: any) => {
Modal.confirm({
title: t('connection.sidebar.delete.confirmTitle'),
content: t('connection.sidebar.delete.confirmContent', { name: node.title }),
onOk: async () => {
const connId = String(node.key);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DeleteConnection !== 'function') {
message.error(t('connection.sidebar.delete.backendUnavailable'));
throw new Error('DeleteConnection unavailable');
}
try {
await backendApp.DeleteConnection(connId);
closeTabsByConnection(connId);
removeConnection(connId);
message.success(t('connection.sidebar.delete.success'));
} catch (error: any) {
message.error(error?.message || t('connection.sidebar.delete.failureFallback'));
throw error;
}
},
});
};
const createConnectionTreeNode = (conn: SavedConnection): TreeNode => ({
title: conn.name,
key: conn.id,
icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 22),
type: 'connection',
dataRef: conn,
isLeaf: false,
});
const getConnectionNodeForAction = (conn: SavedConnection): TreeNode => {
return findTreeNodeByKeyRef.current(treeDataRef.current, conn.id) || createConnectionTreeNode(conn);
};
const handleV2ConnectionContextMenuAction = (node: any, action: V2ConnectionContextMenuActionKey) => {
const connId = String(node?.key || node?.dataRef?.id || '');
if (!connId) return;
switch (action) {
case 'new-db':
setTargetConnection(node);
setIsCreateDbModalOpen(true);
return;
case 'refresh':
refreshConnectionNode(node);
return;
case 'new-query':
addTab({
id: `query-${Date.now()}`,
title: buildConnectionRootQueryTabTitle(),
type: 'query',
connectionId: connId,
dbName: undefined,
query: '',
});
return;
case 'open-sql-file':
handleRunSQLFile(node);
return;
case 'new-command':
addTab({
id: `redis-cmd-${connId}-${Date.now()}`,
title: buildConnectionRootRedisCommandTabTitle(),
type: 'redis-command',
connectionId: connId,
redisDB: 0,
});
return;
case 'open-monitor':
addTab({
id: `redis-monitor-${connId}-${Date.now()}`,
title: buildConnectionRootRedisMonitorTabTitle(),
type: 'redis-monitor',
connectionId: connId,
redisDB: 0,
});
return;
case 'edit':
if (onEditConnection) onEditConnection(node.dataRef);
return;
case 'copy-connection':
void handleDuplicateConnection(node.dataRef as SavedConnection);
return;
case 'disconnect':
void disconnectConnectionNode(node);
return;
case 'delete':
deleteConnectionNode(node);
return;
case 'move-to-ungrouped':
moveConnectionToTag(connId, null);
return;
default:
if (action.startsWith('move-to-tag:')) {
moveConnectionToTag(connId, action.slice('move-to-tag:'.length));
}
}
};
const handleV2ConnectionGroupContextMenuAction = (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => {
const tag = connectionTags.find((item) => item.id === group.id);
if (!tag) return;
if (action === 'edit-group') {
createTagForm.setFieldsValue({ name: tag.name, connectionIds: tag.connectionIds });
setRenameViewTarget({
title: tag.name,
key: `tag-${tag.id}`,
type: 'tag',
dataRef: tag,
});
setIsCreateTagModalOpen(true);
return;
}
if (action === 'delete-group') {
Modal.confirm({
title: t('connection.sidebar.group.deleteConfirmTitle'),
content: t('connection.sidebar.group.deleteConfirmContent', { name: tag.name }),
onOk: () => {
removeConnectionTag(tag.id);
},
});
}
};
return {
getConnectionNodeForAction,
toggleSidebarTablePinned,
handleV2TableContextMenuAction,
handleTableGroupSortAction,
handleV2TableGroupContextMenuAction,
handleV2DatabaseContextMenuAction,
disconnectConnectionNode,
deleteConnectionNode,
handleV2ConnectionContextMenuAction,
handleV2ConnectionGroupContextMenuAction,
};
};