diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 8813531..f52c19b 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -18,4 +18,93 @@ describe('tool center menu entries', () => { expect(snippetIndex).toBeGreaterThan(-1); expect(shortcutIndex).toBeGreaterThan(snippetIndex); }); + + it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => { + expect(appSource).toContain('onToggleAI={toggleAIPanel}'); + expect(appSource).toContain('renderLegacyAIEdgeHandle'); + expect(appSource).toContain('resolveLegacyAIEdgeHandleDockStyle'); + expect(appSource).toContain('data-gonavi-legacy-ai-edge-action="true"'); + expect(appSource).toContain('{!isV2Ui && !aiPanelVisible && ('); + expect(appSource).toContain('{!isV2Ui && ('); + expect(appSource).not.toContain('data-gonavi-ai-entry-action="true"'); + }); + + it('keeps sidebar utility handlers stable so v2 button clicks do not repaint the workspace', () => { + expect(appSource).toContain('const handleOpenToolsModal = useCallback('); + expect(appSource).toContain('const handleOpenSettingsModal = useCallback('); + expect(appSource).toContain('const handleToggleLogPanel = useCallback('); + expect(appSource).toContain('const handleFocusSidebarSearch = useCallback('); + expect(appSource).toContain('const antdTheme = useMemo(() => ({'); + expect(appSource).toContain('theme={antdTheme}'); + expect(appSource).toContain('const sqlLogCount = useStore(state => state.sqlLogs.length);'); + expect(appSource).toContain('onOpenTools={handleOpenToolsModal}'); + expect(appSource).toContain('onOpenSettings={handleOpenSettingsModal}'); + expect(appSource).toContain('onToggleLogPanel={handleToggleLogPanel}'); + expect(appSource).toContain('onFocusCommandSearch={handleFocusSidebarSearch}'); + expect(appSource).toContain('sqlLogCount={sqlLogCount}'); + expect(appSource).not.toContain('onOpenTools={() => setIsToolsModalOpen(true)}'); + expect(appSource).not.toContain('onOpenSettings={() => setIsSettingsModalOpen(true)}'); + expect(appSource).not.toContain('onToggleLogPanel={() => setIsLogPanelOpen((prev) => !prev)}'); + expect(appSource).not.toContain('theme={{'); + expect(appSource).not.toContain('const sqlLogs = useStore(state => state.sqlLogs);'); + }); + + it('uses the v2 green accent for sidebar and log resize guide lines', () => { + expect(appSource).toContain('const resizeGuideColor = isV2Ui'); + expect(appSource).toContain("'var(--gn-accent, #16a34a)'"); + expect(appSource).toContain("darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'"); + }); + + it('mounts heavyweight modals only while they are open', () => { + expect(appSource).toContain('{isModalOpen && ('); + expect(appSource).toContain('{isToolsModalOpen && ('); + expect(appSource).toContain('{isSettingsModalOpen && ('); + expect(appSource).toContain('{isThemeModalOpen && ('); + expect(appSource).toContain('{isShortcutModalOpen && ('); + expect(appSource).toContain('{isAISettingsOpen && ('); + expect(appSource).toContain('{isDriverModalOpen && ('); + expect(appSource).toContain('{isSyncModalOpen && ('); + }); + + it('keeps shortcut manager scrolling inside the modal body', () => { + expect(appSource).toContain('centered'); + expect(appSource).toContain("height: 'min(760px, calc(100vh - 80px))'"); + expect(appSource).toContain("maxHeight: 'calc(100vh - 80px)'"); + expect(appSource).toContain("body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 }"); + expect(appSource).toContain('data-gonavi-shortcut-modal-scroll="true"'); + expect(appSource).toContain("height: '100%'"); + expect(appSource).toContain("overflowY: 'auto'"); + }); + + it('renders recorded shortcuts with platform-specific display labels', () => { + expect(appSource).toContain('getShortcutDisplayLabel'); + expect(appSource).toContain("getShortcutDisplayLabel(binding.combo, platform)"); + }); +}); + +describe('global appearance tokens', () => { + it('publishes v2 font and scale variables for non-AntD chrome', () => { + expect(appSource).toContain("setProperty('--gonavi-font-size'"); + expect(appSource).toContain("setProperty('--gn-ui-scale'"); + expect(appSource).toContain("setProperty('--gn-font-size'"); + expect(appSource).toContain("setProperty('--gn-font-size-sm'"); + expect(appSource).toContain("setProperty('--gn-font-size-xs'"); + expect(appSource).toContain("setProperty('--gn-font-size-mono'"); + expect(appSource).toContain("setProperty('--gn-data-table-font-size'"); + expect(appSource).toContain("setProperty('--gn-sidebar-tree-font-size'"); + expect(appSource).toContain("setProperty('--gn-control-height'"); + expect(appSource).toContain("setProperty('--gn-control-height-sm'"); + expect(appSource).toContain('数据表字体大小'); + expect(appSource).toContain('左侧库表字体大小'); + expect(appSource).toContain('const dataTableFontSizeFollowsGlobal = appearance.dataTableFontSizeFollowGlobal !== false;'); + expect(appSource).toContain('const sidebarTreeFontSizeFollowsGlobal = appearance.sidebarTreeFontSizeFollowGlobal !== false;'); + expect(appSource).toContain('disabled={dataTableFontSizeFollowsGlobal}'); + expect(appSource).toContain('disabled={sidebarTreeFontSizeFollowsGlobal}'); + expect(appSource).toContain("type={dataTableFontSizeFollowsGlobal ? 'primary' : 'default'}"); + expect(appSource).toContain("type={sidebarTreeFontSizeFollowsGlobal ? 'primary' : 'default'}"); + expect(appSource).toContain('dataTableFontSizeFollowGlobal: !dataTableFontSizeFollowsGlobal'); + expect(appSource).toContain('sidebarTreeFontSizeFollowGlobal: !sidebarTreeFontSizeFollowsGlobal'); + expect(appSource).toContain('dataTableFontSize: dataTableFontSizeFollowsGlobal'); + expect(appSource).toContain('sidebarTreeFontSize: sidebarTreeFontSizeFollowsGlobal'); + }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 84bc056..2bfee13 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -10,7 +10,7 @@ import type { JVMAIPlanContext, JVMDiagnosticPlanContext, } from '../types'; -import { DownOutlined } from '@ant-design/icons'; +import { DatabaseOutlined, DownOutlined, HistoryOutlined, TableOutlined, WarningOutlined } from '@ant-design/icons'; import './AIChatPanel.css'; import { AIChatHeader } from './ai/AIChatHeader'; @@ -29,6 +29,8 @@ import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit'; import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool'; import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'; import { toAIRequestMessage } from '../utils/aiMessagePayload'; +import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts'; +import { isMacLikePlatform } from '../utils/appearance'; interface AIChatPanelProps { width?: number; @@ -230,6 +232,7 @@ export const AIChatPanel: React.FC = ({ const [panelWidth, setPanelWidth] = useState(width); const [isResizing, setIsResizing] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); + const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat'); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -245,6 +248,7 @@ export const AIChatPanel: React.FC = ({ const aiChatHistory = useStore(state => state.aiChatHistory); const aiActiveSessionId = useStore(state => state.aiActiveSessionId); + const appearance = useStore(state => state.appearance); const createNewAISession = useStore(state => state.createNewAISession); const addAIChatMessage = useStore(state => state.addAIChatMessage); const updateAIChatMessage = useStore(state => state.updateAIChatMessage); @@ -257,8 +261,17 @@ export const AIChatPanel: React.FC = ({ const connections = useStore(state => state.connections); const tabs = useStore(state => state.tabs); const activeTabId = useStore(state => state.activeTabId); + const sqlLogs = useStore(state => state.sqlLogs); + const aiChatSessions = useStore(state => state.aiChatSessions); + const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId); const aiPanelVisible = useStore(state => state.aiPanelVisible); - const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage); + const isV2Ui = appearance.uiVersion === 'v2'; + const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); + const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding( + state.shortcutOptions, + 'sendAIChatMessage', + activeShortcutPlatform, + )); const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => { const state = useStore.getState(); @@ -476,6 +489,7 @@ export const AIChatPanel: React.FC = ({ }, []); useEffect(() => { + if (messages.length === 0) return; messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' }); }, [messages.length, sending]); @@ -1516,7 +1530,9 @@ SELECT * FROM users WHERE status = 1; } animationFrameId = requestAnimationFrame(() => { const delta = resizeStartX.current - e.clientX; - const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, 280), 700); + const minWidth = isV2Ui ? 300 : 280; + const maxWidth = isV2Ui ? 520 : 700; + const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, minWidth), maxWidth); dragWidthRef.current = newWidth; // 仅更新 ghost 虚线位置,通过绝对定位规避重排 @@ -1552,7 +1568,7 @@ SELECT * FROM users WHERE status = 1; document.body.style.userSelect = ''; document.body.style.pointerEvents = ''; }; - }, [isResizing, onWidthChange]); + }, [isResizing, isV2Ui, onWidthChange]); // 回推幽灵上下文:基于 get_tables 记录进行表级精确匹配(useMemo 缓存,避免每帧重算) const { inferredConnectionId, inferredDbName } = useMemo(() => { @@ -1595,9 +1611,67 @@ SELECT * FROM users WHERE status = 1; const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; return (aiContexts[ck] || []).map(c => `${c.dbName}.${c.tableName}`); }, [activeContext?.connectionId, activeContext?.dbName, aiContexts]); + const aiInsights = useMemo(() => { + const recentLogs = sqlLogs.slice(0, 24); + const slowest = recentLogs + .filter((log) => log.status === 'success') + .sort((a, b) => b.duration - a.duration)[0]; + const errors = recentLogs.filter((log) => log.status === 'error'); + const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length; + const contextCount = contextTableNames.length; + return [ + { + tone: 'info', + title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构', + body: contextCount > 0 + ? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。` + : '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。', + }, + { + tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent', + title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本', + body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。', + }, + { + tone: errors.length > 0 ? 'warn' : 'info', + title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常', + body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。` : '暂无 SQL 日志。'), + }, + { + tone: writeCount > 0 ? 'warn' : 'accent', + title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主', + body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。', + }, + ]; + }, [contextTableNames, sqlLogs]); + + const renderPanelHistoryList = () => { + const sessions = aiChatSessions.slice(0, 8); + if (sessions.length === 0) { + return
暂无历史会话
; + } + return sessions.map((session) => ( + + )); + }; + const effectivePanelMode = isV2Ui ? activePanelMode : 'chat'; return ( -
+
{isResizing && panelRect.current && createPortal( @@ -1622,53 +1696,96 @@ SELECT * FROM users WHERE status = 1; mutedColor={mutedColor} textColor={textColor} overlayTheme={overlayTheme} - onHistoryClick={() => setHistoryOpen(true)} - onClear={createNewAISession} + isV2Ui={isV2Ui} + onHistoryClick={() => { + if (isV2Ui) { + setActivePanelMode('history'); + } else { + setHistoryOpen(true); + } + }} + onClear={() => { + createNewAISession(); + setActivePanelMode('chat'); + }} onSettingsClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} onClose={onClose} messages={messages} sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || '新对话'} + activeMode={effectivePanelMode} + onModeChange={(mode) => { + if (!isV2Ui) return; + setActivePanelMode(mode); + if (mode === 'history') { + setHistoryOpen(false); + } + }} />
- {messages.length === 0 ? ( - { - setInput(prompt); - if (autoSend) { - // Use setTimeout to let setInput render, then trigger send - setTimeout(() => { - const el = textareaRef.current; - if (el) el.focus(); - // Dispatch a synthetic enter to trigger handleSend - // Simpler: just call handleSend directly with the prompt - }, 50); - } - }} - contextTableNames={contextTableNames} - /> - ) : ( - messages.map(msg => ( - { + setInput(prompt); + if (autoSend) { + // Use setTimeout to let setInput render, then trigger send + setTimeout(() => { + const el = textareaRef.current; + if (el) el.focus(); + // Dispatch a synthetic enter to trigger handleSend + // Simpler: just call handleSend directly with the prompt + }, 50); + } + }} + contextTableNames={contextTableNames} + isV2Ui={isV2Ui} /> - )) + ) : ( + messages.map(msg => ( + + )) + ) + )} + + {effectivePanelMode === 'insights' && ( +
+ {aiInsights.map((item) => ( +
+ + {item.tone === 'warn' ? : item.tone === 'accent' ? : } + +
+ {item.title} +

{item.body}

+
+
+ ))} +
+ )} + + {effectivePanelMode === 'history' && ( +
+ {renderPanelHistoryList()} +
)} @@ -1706,6 +1823,7 @@ SELECT * FROM users WHERE status = 1; dynamicModels={dynamicModels} loadingModels={loadingModels} sendShortcutBinding={aiChatSendShortcutBinding} + shortcutPlatform={activeShortcutPlatform} composerNotice={composerNotice} onModelChange={handleModelChange} onFetchModels={fetchDynamicModels} @@ -1716,6 +1834,7 @@ SELECT * FROM users WHERE status = 1; overlayTheme={overlayTheme} contextUsageChars={contextUsageChars} maxContextChars={getDynamicMaxContextChars(activeProvider?.model)} + isV2Ui={isV2Ui} /> {isExportMode ? ( diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 7fdb72c..506d44a 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -26,6 +26,7 @@ const storeState = vi.hoisted(() => ({ enabled: true, opacity: 1, blur: 0, + uiVersion: 'v2', showDataTableVerticalBorders: false, dataTableDensity: 'comfortable', }, @@ -74,7 +75,16 @@ vi.mock('../store', () => ({ vi.mock('../../wailsjs/go/app/App', () => backendApp); vi.mock('@monaco-editor/react', () => ({ - default: ({ value }: { value?: string }) =>
{value}
, + default: (props: { value?: string; language?: string; theme?: string; options?: Record }) => ( +
+ {props.value} +
+ ), })); vi.mock('./ImportPreviewModal', () => ({ @@ -83,6 +93,7 @@ vi.mock('./ImportPreviewModal', () => ({ vi.mock('@ant-design/icons', () => { const Icon = () => ; + return { ReloadOutlined: Icon, ImportOutlined: Icon, @@ -104,6 +115,10 @@ vi.mock('@ant-design/icons', () => { RightOutlined: Icon, RobotOutlined: Icon, SearchOutlined: Icon, + TableOutlined: Icon, + DatabaseOutlined: Icon, + NodeIndexOutlined: Icon, + ThunderboltOutlined: Icon, }; }); @@ -181,6 +196,20 @@ vi.mock('antd', () => { Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null]; const passthrough = ({ children }: any) => <>{children}; + const Segmented = ({ value, options, onChange }: any) => ( +
+ {(options || []).map((option: any) => ( + + ))} +
+ ); return { Table: () => , @@ -193,7 +222,7 @@ vi.mock('antd', () => { Select: () => null, Modal, Checkbox: ({ checked, onChange }: any) => , - Segmented: () => null, + Segmented, Tooltip: passthrough, Popover: passthrough, DatePicker: () => null, @@ -518,4 +547,232 @@ describe('DataGrid DDL interactions', () => { expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users'); expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0); }); + + it('switches the v2 footer field tab into the main fields view', async () => { + storeState.appearance.uiVersion = 'v2'; + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + findButton(renderer!, '字段信息').props.onClick(); + }); + + const content = textContent(renderer!.root); + expect(content).toContain('FIELDS'); + expect(content).toContain('2 个字段'); + expect(content).toContain('id'); + expect(content).toContain('name'); + }); + + it('returns to the legacy table view when v2-only footer views are active during UI switch', async () => { + storeState.appearance.uiVersion = 'v2'; + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + findButton(renderer!, '字段信息').props.onClick(); + }); + expect(textContent(renderer!.root)).toContain('FIELDS'); + + storeState.appearance.uiVersion = 'legacy'; + await act(async () => { + renderer!.update( + , + ); + }); + await waitForEffects(); + + const content = textContent(renderer!.root); + expect(content).not.toContain('FIELDS'); + expect(content).not.toContain('gn-v2-data-grid-fields-view'); + expect(content).toContain('数据预览'); + expect(content).toContain('结果视图'); + expect(content).toContain('字段信息'); + }); + + it('renders the v2 footer DDL view with the Monaco SQL editor', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBShowCreateTable.mockResolvedValueOnce({ + success: true, + data: 'CREATE TABLE users (`id` bigint)', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + findButton(renderer!, '查看 DDL').props.onClick(); + }); + await waitForEffects(); + + const editors = renderer!.root.findAll((node) => node.props['data-monaco-editor'] === 'true'); + expect(editors).toHaveLength(1); + expect(editors[0].props['data-language']).toBe('sql'); + expect(editors[0].props['data-read-only']).toBe('true'); + expect(textContent(editors[0])).toContain('CREATE TABLE users'); + expect(renderer!.root.findAll((node) => node.type === 'pre' && textContent(node).includes('CREATE TABLE users'))).toHaveLength(0); + }); + + it('opens the v2 DDL view as a right sidebar while keeping the table visible', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBShowCreateTable.mockResolvedValueOnce({ + success: true, + data: 'CREATE TABLE users (`id` bigint)', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + findButton(renderer!, '查看 DDL').props.onClick(); + }); + await waitForEffects(); + + await act(async () => { + renderer!.root.findByProps({ 'data-segmented-option': 'side' }).props.onClick(); + }); + + const sideWorkspace = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' }); + expect(sideWorkspace.props.className).toBe('gn-v2-data-grid-split-workspace'); + expect(renderer!.root.findByProps({ 'aria-label': '表 DDL 侧栏' }).props.className).toBe('gn-v2-data-grid-ddl-sidebar'); + expect(renderer!.root.findByProps({ 'data-grid-ddl-view': 'side' }).props.className).toContain('is-side'); + expect(renderer!.root.findAllByType('table')).toHaveLength(1); + expect(sideWorkspace.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px'); + expect(sideWorkspace.props.style['--gn-v2-ddl-sidebar-width']).toBe('420px'); + expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(420); + + const editors = renderer!.root.findAll((node) => node.props['data-monaco-editor'] === 'true'); + expect(editors).toHaveLength(1); + expect(editors[0].props['data-language']).toBe('sql'); + expect(textContent(editors[0])).toContain('CREATE TABLE users'); + }); + + it('previews and commits the v2 DDL sidebar width after dragging the separator', async () => { + storeState.appearance.uiVersion = 'v2'; + backendApp.DBShowCreateTable.mockResolvedValueOnce({ + success: true, + data: 'CREATE TABLE users (`id` bigint)', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + findButton(renderer!, '查看 DDL').props.onClick(); + }); + await waitForEffects(); + + await act(async () => { + renderer!.root.findByProps({ 'data-segmented-option': 'side' }).props.onClick(); + }); + + const container = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' }); + expect(container.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px'); + expect(renderer!.root.findByProps({ 'data-grid-ddl-resize-preview': 'true' }).props.className).toBe('gn-v2-data-grid-ddl-resize-preview'); + + const addEventListenerMock = vi.mocked(document.addEventListener); + const removeEventListenerMock = vi.mocked(document.removeEventListener); + const resizer = renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }); + await act(async () => { + resizer.props.onMouseDown({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 900, + }); + }); + + const mouseMoveHandler = addEventListenerMock.mock.calls.find(([eventName]) => eventName === 'mousemove')?.[1] as ((event: MouseEvent) => void) | undefined; + const mouseUpHandler = addEventListenerMock.mock.calls.find(([eventName]) => eventName === 'mouseup')?.[1] as (() => void) | undefined; + expect(mouseMoveHandler).toBeTypeOf('function'); + expect(mouseUpHandler).toBeTypeOf('function'); + + await act(async () => { + mouseMoveHandler?.({ clientX: 780 } as MouseEvent); + }); + + const movingContainer = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' }); + expect(movingContainer.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px'); + expect(movingContainer.props.style['--gn-v2-ddl-sidebar-width']).toBe('420px'); + expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(420); + + await act(async () => { + mouseUpHandler?.(); + }); + + const resizedContainer = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' }); + expect(resizedContainer.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 540px'); + expect(resizedContainer.props.style['--gn-v2-ddl-sidebar-width']).toBe('540px'); + expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(540); + expect(removeEventListenerMock).toHaveBeenCalledWith('mousemove', mouseMoveHandler); + expect(removeEventListenerMock).toHaveBeenCalledWith('mouseup', mouseUpHandler); + }); }); diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index 50eccef..8df91dd 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { readFileSync } from 'node:fs'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -84,6 +85,12 @@ const createRows = (count: number) => Array.from({ length: count }, (_, i) => ({ })); describe('DataViewer safe editing locator', () => { + it('memoizes the table data viewer so parent-only modal state does not repaint loaded data', () => { + const source = readFileSync(new URL('./DataViewer.tsx', import.meta.url), 'utf8'); + + expect(source).toContain('React.memo(({ tab, isActive = true }) => {'); + }); + const renderAndReload = async (tab: TabData = createTab()) => { let renderer: ReactTestRenderer; await act(async () => { diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 624ebb7..19f3b20 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -255,6 +255,7 @@ type ViewerScrollSnapshot = { }; const viewerFilterSnapshotsByTab = new Map(); +const VIEWER_SCROLL_SNAPSHOT_PERSIST_DELAY_MS = 160; const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => { if (!Array.isArray(conditions)) return []; @@ -289,7 +290,7 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => { }; }; -const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => { +const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ tab, isActive = true }) => { const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]); const [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); @@ -298,6 +299,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct const [loading, setLoading] = useState(false); const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); + const appearance = useStore(state => state.appearance); + const isV2Ui = appearance?.uiVersion === 'v2'; const fetchSeqRef = useRef(0); const countSeqRef = useRef(0); const countKeyRef = useRef(''); @@ -318,6 +321,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct top: initialViewerSnapshot.scrollTop, left: initialViewerSnapshot.scrollLeft, }); + const pendingScrollSnapshotPersistRef = useRef(null); + const scrollSnapshotPersistTimerRef = useRef(null); const initialLoadRef = useRef(false); const skipNextAutoFetchRef = useRef(false); @@ -375,7 +380,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct useEffect(() => { return () => { - persistViewerSnapshot(tab.id); + if (scrollSnapshotPersistTimerRef.current !== null) { + window.clearTimeout(scrollSnapshotPersistTimerRef.current); + scrollSnapshotPersistTimerRef.current = null; + } + const pendingScrollSnapshot = pendingScrollSnapshotPersistRef.current; + pendingScrollSnapshotPersistRef.current = null; + persistViewerSnapshot(tab.id, pendingScrollSnapshot ? { + scrollTop: pendingScrollSnapshot.top, + scrollLeft: pendingScrollSnapshot.left, + } : undefined); }; }, [tab.id, persistViewerSnapshot]); @@ -412,10 +426,18 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => { scrollSnapshotRef.current = snapshot; - persistViewerSnapshot(tab.id, { - scrollTop: snapshot.top, - scrollLeft: snapshot.left, - }); + pendingScrollSnapshotPersistRef.current = snapshot; + if (scrollSnapshotPersistTimerRef.current !== null) return; + scrollSnapshotPersistTimerRef.current = window.setTimeout(() => { + scrollSnapshotPersistTimerRef.current = null; + const pendingScrollSnapshot = pendingScrollSnapshotPersistRef.current; + pendingScrollSnapshotPersistRef.current = null; + if (!pendingScrollSnapshot) return; + persistViewerSnapshot(tab.id, { + scrollTop: pendingScrollSnapshot.top, + scrollLeft: pendingScrollSnapshot.left, + }); + }, VIEWER_SCROLL_SNAPSHOT_PERSIST_DELAY_MS); }, [tab.id, persistViewerSnapshot]); const handleManualTotalCount = useCallback(async () => { @@ -1092,7 +1114,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter return ( -
+
= ({ tab, isAct />
); -}; +}); export default DataViewer; diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index 6a71d4b..6b707d7 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -216,7 +216,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const driverManagerTheme = useMemo( () => buildDriverManagerWorkbenchTheme(darkMode, opacity), - [darkMode, opacity], + [darkMode, opacity, appearance.uiVersion], ); const [loading, setLoading] = useState(false); const [downloadDir, setDownloadDir] = useState(''); diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 3b5de86..1dc4ee1 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -26,12 +26,20 @@ const storeState = vi.hoisted(() => ({ savedQueries: [] as SavedQuery[], saveQuery: vi.fn(), theme: 'light', + appearance: { uiVersion: 'legacy' as const }, sqlFormatOptions: { keywordCase: 'upper' as const }, setSqlFormatOptions: vi.fn(), queryOptions: { maxRows: 5000 }, setQueryOptions: vi.fn(), shortcutOptions: { - runQuery: { enabled: false, combo: '' }, + runQuery: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, + selectCurrentStatement: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, }, activeTabId: 'tab-1', aiPanelVisible: false, diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c51f3ae..8fff448 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import Editor, { type OnMount } from './MonacoEditor'; import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd'; -import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons'; +import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined, DatabaseOutlined } from '@ant-design/icons'; import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition, IndexDefinition } from '../types'; @@ -10,7 +10,7 @@ import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDat import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; -import { getShortcutDisplay, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding } from "../utils/shortcuts"; +import { getShortcutDisplayLabel, getShortcutPlatform, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts"; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; @@ -18,6 +18,7 @@ import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart } from '../utils/sql'; import { resolveCurrentSqlStatementRange } from '../utils/sqlStatementSelection'; +import { isMacLikePlatform } from '../utils/appearance'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator'; @@ -669,12 +670,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const columnsCacheRef = useRef>({}); const saveQuery = useStore(state => state.saveQuery); const theme = useStore(state => state.theme); + const appearance = useStore(state => state.appearance); const darkMode = theme === 'dark'; + const isV2Ui = appearance.uiVersion === 'v2'; const sqlFormatOptions = useStore(state => state.sqlFormatOptions); const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); const shortcutOptions = useStore(state => state.shortcutOptions); + const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); + const runQueryShortcutBinding = useMemo( + () => resolveShortcutBinding(shortcutOptions, 'runQuery', activeShortcutPlatform), + [activeShortcutPlatform, shortcutOptions], + ); + const selectCurrentStatementShortcutBinding = useMemo( + () => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform), + [activeShortcutPlatform, shortcutOptions], + ); const activeTabId = useStore(state => state.activeTabId); const autoFetchVisible = useAutoFetchVisibility(); @@ -689,6 +701,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } return savedQueries.find((item) => item.id === tabId) || null; }, [savedQueries, tab.id, tab.savedQueryId]); + const activeConnectionName = useMemo( + () => connections.find(c => c.id === currentConnectionId)?.name || '未选择连接', + [connections, currentConnectionId], + ); + const queryResultSummary = useMemo(() => { + if (loading) return '执行中'; + if (resultSets.length === 0) return executionError ? '执行失败' : '未执行'; + const totalRows = resultSets.reduce((sum, rs) => sum + (Array.isArray(rs.rows) ? rs.rows.length : 0), 0); + return `${resultSets.length} 组结果 / ${totalRows.toLocaleString()} 行`; + }, [executionError, loading, resultSets]); useEffect(() => { currentConnectionIdRef.current = currentConnectionId; @@ -960,7 +982,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); // Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding - const runBinding = shortcutOptions.runQuery; + const runBinding = runQueryShortcutBinding; if (runBinding?.enabled && runBinding.combo) { const keyBinding = comboToMonacoKeyBinding( runBinding.combo, monaco.KeyMod, monaco.KeyCode @@ -977,7 +999,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } - const selectStatementBinding = shortcutOptions.selectCurrentStatement; + const selectStatementBinding = selectCurrentStatementShortcutBinding; if (selectStatementBinding?.enabled && selectStatementBinding.combo) { const keyBinding = comboToMonacoKeyBinding( selectStatementBinding.combo, monaco.KeyMod, monaco.KeyCode @@ -2215,7 +2237,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }, [activeTabId, tab.id]); useEffect(() => { - const binding = shortcutOptions.runQuery; + const binding = runQueryShortcutBinding; if (!binding?.enabled || !binding.combo) { return; } @@ -2240,7 +2262,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return () => { window.removeEventListener('keydown', handleRunShortcut, true); }; - }, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]); + }, [activeTabId, tab.id, runQueryShortcutBinding, handleRun]); // Re-register Monaco internal keybinding when runQuery shortcut changes useEffect(() => { @@ -2253,7 +2275,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const monaco = monacoRef.current; if (!editor || !monaco) return; - const binding = shortcutOptions.runQuery; + const binding = runQueryShortcutBinding; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); @@ -2274,7 +2296,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc runQueryActionRef.current = null; } }; - }, [shortcutOptions.runQuery]); + }, [runQueryShortcutBinding]); useEffect(() => { if (selectCurrentStatementActionRef.current) { @@ -2286,7 +2308,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const monaco = monacoRef.current; if (!editor || !monaco) return; - const binding = shortcutOptions.selectCurrentStatement; + const binding = selectCurrentStatementShortcutBinding; if (!binding?.enabled || !binding.combo) return; const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); @@ -2305,7 +2327,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc selectCurrentStatementActionRef.current = null; } }; - }, [shortcutOptions.selectCurrentStatement]); + }, [selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]); useEffect(() => { const handleRunActiveQuery = () => { @@ -2505,7 +2527,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; return ( -
+
-
-
+
+ {isV2Ui && ( +
+
+ SQL WORKSPACE + {currentDb || '未选择数据库'} +
+
+ {activeConnectionName} + {queryResultSummary} +
+
+ )} +
setV2CommandSearchValue(event.target.value)} + onKeyDown={handleV2CommandSearchKeyDown} + placeholder="搜索表、连接、动作... 或问 AI" + /> + esc +
+
+ {renderV2CommandSearchSection('跳转 · GO TO', filteredCommandSearchTreeItems)} + {renderV2CommandSearchSection('AI · ASK', commandSearchAiItem)} + {renderV2CommandSearchSection('动作 · ACTIONS', filteredCommandSearchActionItems)} + {renderV2CommandSearchSection('近期查询 · RECENT', filteredCommandSearchRecentItems)} + {commandSearchFlatItems.length === 0 ? ( +
+ {emptyCopy} +
+ ) : null} +
+
+ 导航 + 选择 + @只搜表对象 + ?发送给 AI +
+
+
+ ); + }; + + expandConnectionFromRailRef.current = (connectionId: string) => { + const conn = connections.find((item) => item.id === connectionId); + if (conn) { + selectConnectionFromRail(conn); + } + }; const getNodeMenuItems = (node: any): MenuProps['items'] => { const conn = node.dataRef as SavedConnection; @@ -3848,27 +5502,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> key: 'sort-by-name', label: '按名称排序', icon: currentSort === 'name' ? : null, - onClick: () => { - setTableSortPreference(groupData.id, groupData.dbName, 'name'); - const dbNode = { - key: `${groupData.id}-${groupData.dbName}`, - dataRef: groupData - }; - loadTables(dbNode); - } + onClick: () => handleTableGroupSortAction(node, 'name') }, { key: 'sort-by-frequency', label: '按使用频率排序', icon: currentSort === 'frequency' ? : null, - onClick: () => { - setTableSortPreference(groupData.id, groupData.dbName, 'frequency'); - const dbNode = { - key: `${groupData.id}-${groupData.dbName}`, - dataRef: groupData - }; - loadTables(dbNode); - } + onClick: () => handleTableGroupSortAction(node, 'frequency') } ]; } @@ -4289,11 +5929,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> key: 'rename-db', label: '重命名数据库', icon: , - onClick: () => { - setRenameDbTarget(node); - renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' }); - setIsRenameDbModalOpen(true); - } + onClick: () => handleV2DatabaseContextMenuAction(node, 'rename-db') }, { key: 'danger-zone', @@ -4305,7 +5941,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> label: '删除数据库', icon: , danger: true, - onClick: () => handleDeleteDatabase(node) + onClick: () => handleV2DatabaseContextMenuAction(node, 'drop-db') } ] }, @@ -4313,63 +5949,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> key: 'refresh', label: '刷新', icon: , - onClick: () => loadTables(node) + onClick: () => handleV2DatabaseContextMenuAction(node, 'refresh') }, { key: 'export-db-schema', label: '导出全部表结构 (SQL)', icon: , - onClick: () => handleExportDatabaseSQL(node, false) + onClick: () => handleV2DatabaseContextMenuAction(node, 'export-db-schema') }, { key: 'backup-db-sql', label: '备份全部表 (结构+数据 SQL)', icon: , - onClick: () => handleExportDatabaseSQL(node, true) + onClick: () => handleV2DatabaseContextMenuAction(node, 'backup-db-sql') }, { type: 'divider' }, { key: 'disconnect-db', label: '关闭数据库', icon: , - onClick: () => { - 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("已关闭数据库"); - } + onClick: () => handleV2DatabaseContextMenuAction(node, 'disconnect-db') }, { key: 'new-query', label: '新建查询', icon: , - onClick: () => { - addTab({ - id: `query-${Date.now()}`, - title: `新建查询 (${node.title})`, - type: 'query', - connectionId: node.dataRef.id, - dbName: node.title, - query: '' - }); - } + onClick: () => handleV2DatabaseContextMenuAction(node, 'new-query') }, { key: 'run-sql', label: '运行外部SQL文件', icon: , - onClick: () => handleRunSQLFile(node) + onClick: () => handleV2DatabaseContextMenuAction(node, 'run-sql') } ]; } else if (node.type === 'view') { @@ -4710,7 +6321,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> } const statusBadge = node.type === 'connection' || node.type === 'database' ? ( - + isV2Ui + ?