From b388b0822600726dea5325e03c8a334d37567c0f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 3 Jul 2026 22:11:49 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sql-file-execution):=20?= =?UTF-8?q?=E5=A4=96=E9=83=A8SQL=E6=89=A7=E8=A1=8C=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=B9=B6=E4=BC=98=E5=8C=96=E5=A4=A7?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端将运行外部SQL改为工作台Tab并复用导出式进度视图 - 后端新增仅选择文件元数据接口并优化流式读取与批量拼接 - 补充执行Runner、国际化文案和定向测试 --- .../components/SQLFileExecutionWorkbench.tsx | 575 ++++++++++++++++++ frontend/src/components/Sidebar.tsx | 8 - frontend/src/components/TabManager.tsx | 6 + .../sidebar/SidebarExternalSqlWorkflow.tsx | 193 +++--- .../useSQLFileExecutionRunner.test.tsx | 180 ++++++ .../components/useSQLFileExecutionRunner.ts | 295 +++++++++ frontend/src/types.ts | 3 + .../src/utils/sqlFileExecutionTab.test.ts | 36 ++ frontend/src/utils/sqlFileExecutionTab.ts | 46 ++ internal/app/methods_file.go | 80 ++- internal/app/sql_split_stream.go | 6 +- shared/i18n/de-DE.json | 16 + shared/i18n/en-US.json | 16 + shared/i18n/ja-JP.json | 16 + shared/i18n/ru-RU.json | 16 + shared/i18n/zh-CN.json | 16 + shared/i18n/zh-TW.json | 16 + 17 files changed, 1386 insertions(+), 138 deletions(-) create mode 100644 frontend/src/components/SQLFileExecutionWorkbench.tsx create mode 100644 frontend/src/components/useSQLFileExecutionRunner.test.tsx create mode 100644 frontend/src/components/useSQLFileExecutionRunner.ts create mode 100644 frontend/src/utils/sqlFileExecutionTab.test.ts create mode 100644 frontend/src/utils/sqlFileExecutionTab.ts diff --git a/frontend/src/components/SQLFileExecutionWorkbench.tsx b/frontend/src/components/SQLFileExecutionWorkbench.tsx new file mode 100644 index 00000000..61e62851 --- /dev/null +++ b/frontend/src/components/SQLFileExecutionWorkbench.tsx @@ -0,0 +1,575 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Button, Empty, Progress, Typography } from 'antd'; +import { + ClockCircleOutlined, + FileTextOutlined, + ReloadOutlined, + StopOutlined, +} from '@ant-design/icons'; + +import { ExecuteSQLFile, CancelSQLFileExecution } from '../../wailsjs/go/app/App'; +import { useStore } from '../store'; +import type { TabData } from '../types'; +import { t } from '../i18n'; +import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { resolveConnectionHostSummary } from '../utils/tabDisplay'; +import { formatExportElapsed, resolveExportElapsedMs } from '../utils/exportProgress'; +import { + useSQLFileExecutionRunner, + type SQLFileExecutionRunnerStatus, + type SQLFileExecutionState, +} from './useSQLFileExecutionRunner'; + +const { Paragraph, Text, Title } = Typography; + +type SQLFileExecutionHistoryEntry = SQLFileExecutionState & { + requestKey: string; +}; + +const EMPTY_HISTORY: SQLFileExecutionHistoryEntry[] = []; + +const formatDateTime = (timestamp: number): string => { + if (!Number.isFinite(timestamp) || timestamp <= 0) { + return '-'; + } + return new Date(timestamp).toLocaleString('zh-CN', { hour12: false }); +}; + +const resolveStatusMeta = (status: SQLFileExecutionRunnerStatus): { + label: string; + border: string; + bg: string; + text: string; +} => { + const meta: Record = { + idle: { + label: t('sidebar.sql_file_exec.workbench.empty.not_started'), + border: 'rgba(148, 163, 184, 0.35)', + bg: 'rgba(148, 163, 184, 0.12)', + text: '#475467', + }, + start: { + label: t('sidebar.sql_file_exec.workbench.stage.preparing'), + border: 'rgba(59, 130, 246, 0.3)', + bg: 'rgba(59, 130, 246, 0.12)', + text: '#1d4ed8', + }, + running: { + label: t('sidebar.sql_file_exec.status.running'), + border: 'rgba(16, 185, 129, 0.3)', + bg: 'rgba(16, 185, 129, 0.14)', + text: '#047857', + }, + done: { + label: t('sidebar.sql_file_exec.status.done'), + border: 'rgba(34, 197, 94, 0.3)', + bg: 'rgba(34, 197, 94, 0.14)', + text: '#15803d', + }, + cancelled: { + label: t('sidebar.sql_file_exec.status.cancelled'), + border: 'rgba(249, 115, 22, 0.3)', + bg: 'rgba(249, 115, 22, 0.12)', + text: '#c2410c', + }, + error: { + label: t('sidebar.sql_file_exec.status.error'), + border: 'rgba(239, 68, 68, 0.32)', + bg: 'rgba(239, 68, 68, 0.12)', + text: '#dc2626', + }, + }; + return meta[status]; +}; + +const renderStatusPill = (status: SQLFileExecutionRunnerStatus) => { + const meta = resolveStatusMeta(status); + return ( + + {meta.label} + + ); +}; + +const formatExecutionSummary = (executed: number, failed: number): string => + `${t('sidebar.sql_file_exec.executed_label')}${executed.toLocaleString()}${t('sidebar.sql_file_exec.rows_separator')}${failed.toLocaleString()}${t('sidebar.sql_file_exec.rows_suffix')}`; + +const resolveProgressStatus = (status: SQLFileExecutionRunnerStatus): 'active' | 'success' | 'exception' | 'normal' => { + if (status === 'done') return 'success'; + if (status === 'error') return 'exception'; + if (status === 'start' || status === 'running') return 'active'; + return 'normal'; +}; + +const SQLFileExecutionWorkbench: React.FC<{ tab: TabData }> = ({ tab }) => { + const connections = useStore((state) => state.connections); + const theme = useStore((state) => state.theme); + const [nowTick, setNowTick] = useState(() => Date.now()); + const [historyEntries, setHistoryEntries] = useState(EMPTY_HISTORY); + const lastArchivedJobIdRef = useRef(''); + const lastRequestKeyRef = useRef(''); + const darkMode = theme === 'dark'; + const shellBg = darkMode ? '#101319' : '#f5f7fb'; + const panelBg = darkMode ? '#161b22' : '#ffffff'; + const panelBorder = darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(15,23,42,0.08)'; + const dividerColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'; + const headingColor = darkMode ? 'rgba(255,255,255,0.96)' : '#101828'; + const secondaryTextColor = darkMode ? 'rgba(255,255,255,0.68)' : '#667085'; + const subtleBg = darkMode ? 'rgba(255,255,255,0.04)' : '#f8fafc'; + const connection = useMemo( + () => connections.find((item) => item.id === String(tab.connectionId || '').trim()), + [connections, tab.connectionId], + ); + const connectionConfig = useMemo( + () => (connection ? buildRpcConnectionConfig(connection.config) : null), + [connection], + ); + const hostSummary = useMemo( + () => resolveConnectionHostSummary(connection?.config), + [connection?.config], + ); + const { state, reset, cancelExecution, runSQLFileExecutionWithProgress, isRunning } = useSQLFileExecutionRunner({ + showToast: true, + }); + + useEffect(() => { + if (!state.startedAt || state.finishedAt > 0) { + return undefined; + } + const timer = globalThis.setInterval(() => { + setNowTick(Date.now()); + }, 1000); + return () => { + globalThis.clearInterval(timer); + }; + }, [state.finishedAt, state.startedAt]); + + useEffect(() => { + if (!state.jobId) { + return; + } + if (state.status !== 'done' && state.status !== 'cancelled' && state.status !== 'error') { + return; + } + if (lastArchivedJobIdRef.current === state.jobId) { + return; + } + lastArchivedJobIdRef.current = state.jobId; + setHistoryEntries((prev) => [ + { + ...state, + requestKey: String(tab.sqlFileExecutionRequestKey || '').trim(), + }, + ...prev.filter((entry) => entry.jobId !== state.jobId), + ].slice(0, 10)); + }, [state, tab.sqlFileExecutionRequestKey]); + + const startExecution = React.useCallback(async () => { + const filePath = String(tab.filePath || '').trim(); + if (!connectionConfig || !filePath) { + return; + } + await runSQLFileExecutionWithProgress({ + title: tab.title || t('sidebar.sql_file_exec.title'), + filePath, + fileSizeMB: tab.sqlFileExecutionFileSizeMB, + run: (jobId) => ExecuteSQLFile(connectionConfig as any, tab.dbName || '', filePath, jobId), + cancel: (jobId) => { + CancelSQLFileExecution(jobId); + }, + }); + }, [ + connectionConfig, + runSQLFileExecutionWithProgress, + tab.dbName, + tab.filePath, + tab.sqlFileExecutionFileSizeMB, + tab.title, + ]); + + useEffect(() => { + const requestKey = String(tab.sqlFileExecutionRequestKey || '').trim(); + if (!requestKey || requestKey === lastRequestKeyRef.current) { + return; + } + if (!connectionConfig || !String(tab.filePath || '').trim()) { + return; + } + lastRequestKeyRef.current = requestKey; + void startExecution(); + }, [connectionConfig, startExecution, tab.filePath, tab.sqlFileExecutionRequestKey]); + + const currentElapsedMs = useMemo( + () => resolveExportElapsedMs(state.startedAt, state.finishedAt, nowTick), + [nowTick, state.finishedAt, state.startedAt], + ); + const progressPercent = Math.max(0, Math.min(100, Number(state.percent) || 0)); + const currentSummary = formatExecutionSummary(state.executed, state.failed); + const historyList = useMemo( + () => historyEntries.filter((entry) => entry.jobId !== state.jobId), + [historyEntries, state.jobId], + ); + + return ( +
+
+ + {t('sidebar.sql_file_exec.title')} + +
+ {t('sidebar.sql_file_exec.workbench.helper.auto_run')} +
+
+ +
+
+
+
+ {t('sidebar.sql_file_exec.workbench.section.config')} +
+
+ {t('data_export.label.connection')} + {connection?.name || '-'} + + {t('data_export.label.database')} + {tab.dbName || '-'} + + {t('sidebar.sql_file_exec.workbench.label.file_path')} + + {tab.filePath || '-'} + + + {t('sidebar.sql_file_exec.file_size').replace(/[::]\s*$/, '')} + {tab.sqlFileExecutionFileSizeMB ? `${tab.sqlFileExecutionFileSizeMB} MB` : '-'} + + {t('data_export.label.host')} + {hostSummary || '-'} +
+
+ + {!connectionConfig ? ( + + ) : null} + +
+
+ {t('sidebar.sql_file_exec.workbench.helper.reuse')} +
+
+ {isRunning ? ( + + ) : ( + + )} + {(state.status === 'done' || state.status === 'cancelled' || state.status === 'error') ? ( + + ) : null} +
+
+
+ +
+
+
+
+
+
+ {t('data_export.workbench.section.current_task')} +
+ {renderStatusPill(state.status)} +
+ + {state.title || tab.title || t('sidebar.sql_file_exec.title')} + +
+ {state.jobId ? `${state.filePath || tab.filePath || '-'} · ${currentSummary}` : t('sidebar.sql_file_exec.workbench.description.current_task_empty')} +
+
+ +
+
+
+ {t('sidebar.sql_file_exec.workbench.label.elapsed')} +
+
+ + {state.startedAt ? formatExportElapsed(currentElapsedMs) : '--:--'} +
+
+
+
+ {t('sidebar.sql_file_exec.workbench.label.started_at')} +
+
{formatDateTime(state.startedAt)}
+
+
+
+ {t('sidebar.sql_file_exec.workbench.label.file_path')} +
+
+ {state.filePath || tab.filePath || '-'} +
+
+
+
+ {t('sidebar.sql_file_exec.workbench.label.progress_summary')} +
+
{state.jobId ? currentSummary : '-'}
+
+
+
+ + {state.jobId ? ( + <> +
+ +
+ +
+
+
+ {t('sidebar.sql_file_exec.workbench.label.current_stage')} +
+ {state.stage || resolveStatusMeta(state.status).label} +
+
+
+ {t('sidebar.sql_file_exec.workbench.label.file_path')} +
+ + {state.filePath || '-'} + +
+
+ + {state.currentSQL ? ( +
+
+ {t('sidebar.sql_file_exec.workbench.label.current_sql')} +
+
+ {state.currentSQL} +
+
+ ) : null} + + {state.message ? ( + + ) : null} + + ) : ( +
+ +
+ )} +
+ +
+
+
+
+ {t('data_export.workbench.section.history')} +
+
+ {t('sidebar.sql_file_exec.workbench.empty.history')} +
+
+
+
+ {historyList.length.toLocaleString()} +
+ {historyList.length > 0 ? ( + + ) : null} +
+
+ + {historyList.length > 0 ? ( +
+ {historyList.map((entry, index) => { + const elapsed = formatExportElapsed(resolveExportElapsedMs(entry.startedAt, entry.finishedAt, nowTick)); + return ( +
+
+
+ {entry.title || tab.title} + {renderStatusPill(entry.status)} + + {formatExecutionSummary(entry.executed, entry.failed)} + +
+
+ {entry.stage || resolveStatusMeta(entry.status).label} +
+ {entry.message ? ( +
+ {entry.message} +
+ ) : null} +
+ +
+
+ {t('sidebar.sql_file_exec.workbench.label.started_at')} + {formatDateTime(entry.startedAt)} + + {t('sidebar.sql_file_exec.workbench.label.elapsed')} + {elapsed} + + {t('sidebar.sql_file_exec.workbench.label.file_path')} + + {entry.filePath || '-'} + +
+
+
+ ); + })} +
+ ) : ( +
+ {t('sidebar.sql_file_exec.workbench.empty.history')} +
+ )} +
+
+
+
+ ); +}; + +export default SQLFileExecutionWorkbench; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e0e3f0c8..09ca702f 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -31,7 +31,6 @@ import { export { formatSidebarDriverAgentUpdateWarning } from './sidebar/useSidebarTreeLoaders'; import { ExternalSQLFileModal, - SQLFileExecutionModal, useSidebarExternalSqlWorkflow, } from './sidebar/SidebarExternalSqlWorkflow'; export { @@ -1219,7 +1218,6 @@ const Sidebar: React.FC<{ handleRemoveExternalSQLDirectory, handleRefreshExternalSQLDirectory, externalSQLFileModalProps, - sqlFileExecutionModalProps, } = useSidebarExternalSqlWorkflow({ connections, externalSQLDirectories, @@ -3184,12 +3182,6 @@ const Sidebar: React.FC<{ handleCheckAllDb={handleCheckAllDb} handleInvertSelectionDb={handleInvertSelectionDb} /> - - setFindInDbContext({ open: false, connectionId: '', dbName: '' })} diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 2c2b566d..e72361ba 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -19,6 +19,7 @@ import TriggerViewer from './TriggerViewer'; import DefinitionViewer from './DefinitionViewer'; import TableOverview from './TableOverview'; import TableExportWorkbench from './TableExportWorkbench'; +import SQLFileExecutionWorkbench from './SQLFileExecutionWorkbench'; import JVMOverview from './JVMOverview'; import JVMResourceBrowser from './JVMResourceBrowser'; import JVMAuditViewer from './JVMAuditViewer'; @@ -50,6 +51,7 @@ const getTabKindLabel = (tab: TabData): string => { if (tab.type === 'design') return t('tab_manager.kind_badge.design'); if (tab.type === 'table-overview') return t('tab_manager.kind_badge.table_overview'); if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export'); + if (tab.type === 'sql-file-execution') return t('sidebar.sql_file_exec.title'); if (tab.type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis'); if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis'); if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm'); @@ -74,6 +76,7 @@ const getTabKindTooltipLabel = (tab: TabData): string => { if (tab.type === 'design') return t('tab_manager.hover.kind.design'); if (tab.type === 'table-overview') return t('tab_manager.hover.kind.table_overview'); if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export'); + if (tab.type === 'sql-file-execution') return t('sidebar.sql_file_exec.title'); if (tab.type === 'sql-analysis') return t('tab_manager.hover.kind.sql_analysis'); if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys'); if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command'); @@ -424,6 +427,9 @@ const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ if (tab.type === 'table-export') { return ; } + if (tab.type === 'sql-file-execution') { + return ; + } if (tab.type === 'sql-analysis') { return ; } diff --git a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx index 9c45a6e3..0a279fe5 100644 --- a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx +++ b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx @@ -1,10 +1,11 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Button, Form, Input, Progress, message } from 'antd'; import type { FormInstance } from 'antd/es/form'; import Modal from '../common/ResizableDraggableModal'; import type { SavedConnection, ExternalSQLDirectory } from '../../types'; import { noAutoCapInputProps } from '../../utils/inputAutoCap'; import { buildExternalSQLDirectoryId, buildExternalSQLTabId } from '../../utils/externalSqlTree'; +import { buildSQLFileExecutionWorkbenchTab } from '../../utils/sqlFileExecutionTab'; import { t } from '../../i18n'; import { resolveSidebarNodeConnectionId } from '../sidebarV2Utils'; import { @@ -13,8 +14,6 @@ import { } from '../sidebarCoreUtils'; import { OpenSQLFile, - ExecuteSQLFile, - CancelSQLFileExecution, SelectSQLDirectory, ReadSQLFile, CreateSQLFile, @@ -24,7 +23,6 @@ import { RenameSQLFile, RenameSQLDirectory, } from '../../../wailsjs/go/app/App'; -import { EventsOn } from '../../../wailsjs/runtime/runtime'; export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; @@ -88,19 +86,6 @@ type SQLFileExecutionModalProps = { onClose: () => void; }; -const createInitialSQLFileExecutionState = (): SQLFileExecutionState => ({ - open: false, - jobId: '', - fileSizeMB: '', - status: 'running', - executed: 0, - failed: 0, - total: 0, - percent: 0, - currentSQL: '', - resultMessage: '', -}); - const normalizeExternalSQLFileName = (rawName: unknown): string => { const name = String(rawName || '').trim(); if (!name) return ''; @@ -143,6 +128,9 @@ const normalizeSQLFileDialogData = (data: unknown): { content: string; filePath: }; }; +const buildSQLFileExecutionRequestKey = (): string => + `sql-file-execution-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const resolveSQLFileExecutionStatusLabel = (status: SQLFileExecutionStatus): string => { switch (status) { case 'done': @@ -324,80 +312,72 @@ export const useSidebarExternalSqlWorkflow = ({ const [externalSQLFileForm] = Form.useForm(); const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState('create'); const [externalSQLFileTarget, setExternalSQLFileTarget] = useState(null); - const [sqlFileExecState, setSqlFileExecState] = useState(createInitialSQLFileExecutionState); - const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => { - const jobId = `sqlfile-${Date.now()}`; - setSqlFileExecState({ - open: true, - jobId, - fileSizeMB, - status: 'running', - executed: 0, - failed: 0, - total: 0, - percent: 0, - currentSQL: '', - resultMessage: '', - }); + const selectSQLFileForExecution = useCallback(async () => { + const backendApp = typeof window !== 'undefined' ? (window as any).go?.app?.App : undefined; + if (typeof backendApp?.SelectSQLFileForExecution === 'function') { + return backendApp.SelectSQLFileForExecution(); + } + return OpenSQLFile(); + }, []); - const offProgress = EventsOn('sqlfile:progress', (event: any) => { - if (!event || event.jobId !== jobId) return; - setSqlFileExecState(prev => ({ - ...prev, - status: event.status || prev.status, - executed: typeof event.executed === 'number' ? event.executed : prev.executed, - failed: typeof event.failed === 'number' ? event.failed : prev.failed, - total: typeof event.total === 'number' ? event.total : prev.total, - percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent, - currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL, - })); - }); - - ExecuteSQLFile(config, dbName, filePath, jobId).then(res => { - offProgress(); - setSqlFileExecState(prev => ({ - ...prev, - status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'), - percent: 100, - resultMessage: res.message || '', - })); - }).catch(err => { - offProgress(); - setSqlFileExecState(prev => ({ - ...prev, - status: 'error', - resultMessage: String(err?.message || err), - })); - }); - }; + const openSQLFileExecutionWorkbench = useCallback(({ + connectionId, + dbName, + filePath, + fileName, + fileSizeMB, + }: { + connectionId: string; + dbName?: string; + filePath: string; + fileName?: string; + fileSizeMB?: string; + }): boolean => { + const normalizedConnectionId = String(connectionId || '').trim(); + const normalizedFilePath = String(filePath || '').trim(); + if (!normalizedConnectionId || !normalizedFilePath) { + return false; + } + const conn = connections.find((item) => item.id === normalizedConnectionId); + if (!conn) { + message.error(t('sidebar.message.connection_config_not_found')); + return false; + } + addTab(buildSQLFileExecutionWorkbenchTab({ + connectionId: normalizedConnectionId, + dbName: String(dbName || '').trim() || undefined, + filePath: normalizedFilePath, + fileName: String(fileName || '').trim() || undefined, + fileSizeMB: String(fileSizeMB || '').trim() || undefined, + requestKey: buildSQLFileExecutionRequestKey(), + })); + return true; + }, [addTab, connections]); const handleRunSQLFile = async (node: any) => { - const res = await OpenSQLFile(); + const connectionId = node.type === 'connection' + ? String(node.key || '').trim() + : String(node?.dataRef?.id || '').trim(); + const dbName = String(node?.dataRef?.dbName || '').trim(); + if (!connectionId) { + message.warning(t('sidebar.message.select_connection_or_database_first')); + return; + } + + const res = await selectSQLFileForExecution(); if (res.success) { const data = normalizeSQLFileDialogData(res.data); - if (data.isLargeFile) { - const connId = node.type === 'connection' ? node.key : node.dataRef?.id; - const dbName = node.dataRef?.dbName || ''; - const conn = connections.find(c => c.id === connId); - if (!conn) { - message.error(t('sidebar.message.connection_config_not_found')); - return; - } - startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB || ''); + if (!data.filePath) { + message.error(t('sidebar.message.sql_file_path_incomplete')); return; } - - const { dbName, id } = node.dataRef; - const connectionId = node.type === 'connection' ? String(node.key) : String(id || node.dataRef.id || ''); - addTab({ - id: data.filePath ? buildExternalSQLTabId(connectionId, dbName || '', data.filePath) : `query-${Date.now()}`, - title: data.fileName || t('sidebar.sql_file_exec.title'), - type: 'query', + openSQLFileExecutionWorkbench({ connectionId, dbName: dbName, - query: data.content, - filePath: data.filePath || undefined, + filePath: data.filePath, + fileName: data.fileName, + fileSizeMB: data.fileSizeMB, }); } else if (res.message !== '已取消') { message.error(t('sidebar.message.read_file_failed', { error: res.message })); @@ -410,27 +390,19 @@ export const useSidebarExternalSqlWorkflow = ({ message.warning(t('sidebar.message.select_connection_or_database_first')); return; } - const res = await OpenSQLFile(); + const res = await selectSQLFileForExecution(); if (res.success) { const data = normalizeSQLFileDialogData(res.data); - if (data.isLargeFile) { - const conn = connections.find(c => c.id === ctx.connectionId); - if (!conn) { - message.error(t('sidebar.message.connection_config_not_found')); - return; - } - startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB || ''); + if (!data.filePath) { + message.error(t('sidebar.message.sql_file_path_incomplete')); return; } - - addTab({ - id: data.filePath ? buildExternalSQLTabId(ctx.connectionId, ctx.dbName || '', data.filePath) : `query-${Date.now()}`, - title: data.fileName || t('sidebar.sql_file_exec.title'), - type: 'query', + openSQLFileExecutionWorkbench({ connectionId: ctx.connectionId, - dbName: ctx.dbName || undefined, - query: data.content, - filePath: data.filePath || undefined, + dbName: ctx.dbName || '', + filePath: data.filePath, + fileName: data.fileName, + fileSizeMB: data.fileSizeMB, }); } else if (res.message !== '已取消') { message.error(t('sidebar.message.read_file_failed', { error: res.message })); @@ -486,12 +458,13 @@ export const useSidebarExternalSqlWorkflow = ({ message.warning(t('sidebar.message.select_host_before_large_sql_file')); return; } - const conn = connections.find((item) => item.id === connectionId); - if (!conn) { - message.error(t('sidebar.message.connection_config_not_found')); - return; - } - startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB); + openSQLFileExecutionWorkbench({ + connectionId, + dbName, + filePath: String((data as Record).filePath || '').trim() || filePath, + fileName, + fileSizeMB: String((data as Record).fileSizeMB || '').trim() || undefined, + }); return; } @@ -778,15 +751,6 @@ export const useSidebarExternalSqlWorkflow = ({ message.success(t('sidebar.message.external_sql_directory_refreshed')); }; - const cancelSQLFileExecution = () => { - CancelSQLFileExecution(sqlFileExecState.jobId); - setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' })); - }; - - const closeSQLFileExecutionModal = () => { - setSqlFileExecState(prev => ({ ...prev, open: false })); - }; - return { handleRunSQLFile, handleOpenSQLFileFromToolbar, @@ -808,10 +772,5 @@ export const useSidebarExternalSqlWorkflow = ({ onOk: handleExternalSQLFileModalOk, onCancel: closeExternalSQLFileModal, }, - sqlFileExecutionModalProps: { - state: sqlFileExecState, - onCancelExecution: cancelSQLFileExecution, - onClose: closeSQLFileExecutionModal, - }, }; }; diff --git a/frontend/src/components/useSQLFileExecutionRunner.test.tsx b/frontend/src/components/useSQLFileExecutionRunner.test.tsx new file mode 100644 index 00000000..65fd3cd0 --- /dev/null +++ b/frontend/src/components/useSQLFileExecutionRunner.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setCurrentLanguage } from '../i18n'; +import { useSQLFileExecutionRunner } from './useSQLFileExecutionRunner'; + +const runtimeApi = vi.hoisted(() => { + let progressHandler: ((event: any) => void) | null = null; + return { + EventsOn: vi.fn((eventName: string, handler: (event: any) => void) => { + if (eventName === 'sqlfile:progress') { + progressHandler = handler; + } + return () => { + if (progressHandler === handler) { + progressHandler = null; + } + }; + }), + emitProgress: (event: any) => { + progressHandler?.(event); + }, + reset: () => { + progressHandler = null; + }, + }; +}); + +const messageApi = vi.hoisted(() => ({ + warning: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), +})); + +vi.mock('../../wailsjs/runtime/runtime', () => runtimeApi); + +vi.mock('antd', () => ({ + message: messageApi, +})); + +describe('useSQLFileExecutionRunner', () => { + let runner: ReturnType | null = null; + let renderer: ReactTestRenderer | null = null; + let now = 1_000; + + const renderRunner = (showToast = false) => { + const Harness = () => { + runner = useSQLFileExecutionRunner({ showToast }); + return null; + }; + + act(() => { + renderer = create(); + }); + }; + + beforeEach(() => { + runner = null; + renderer = null; + now = 1_000; + setCurrentLanguage('zh-CN'); + runtimeApi.reset(); + runtimeApi.EventsOn.mockClear(); + messageApi.warning.mockReset(); + messageApi.success.mockReset(); + messageApi.error.mockReset(); + messageApi.info.mockReset(); + vi.useFakeTimers(); + vi.spyOn(Date, 'now').mockImplementation(() => now); + }); + + afterEach(() => { + act(() => { + renderer?.unmount(); + }); + setCurrentLanguage('en-US'); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('starts timing after backend progress arrives and keeps execution details in sync', async () => { + renderRunner(); + + let resolveRun!: (value: { success: boolean; message: string }) => void; + const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => { + resolveRun = resolve; + }); + + let runPromise: Promise<{ success: boolean; message: string } | null> | null = null; + await act(async () => { + runPromise = runner?.runSQLFileExecutionWithProgress({ + title: 'seed.sql', + filePath: 'D:/sql/seed.sql', + fileSizeMB: '512.5', + run: async () => pendingRun, + }) || null; + await Promise.resolve(); + }); + + expect(runner?.state.status).toBe('start'); + expect(runner?.state.stage).toBe('准备执行'); + expect(runner?.state.startedAt).toBe(0); + expect(runner?.state.filePath).toBe('D:/sql/seed.sql'); + + const jobId = runner?.state.jobId || ''; + now = 8_000; + act(() => { + runtimeApi.emitProgress({ + jobId, + status: 'running', + executed: 120, + failed: 1, + total: 500, + percent: 24, + currentSQL: 'insert into demo values (1)', + }); + vi.advanceTimersByTime(20); + }); + + expect(runner?.state.status).toBe('running'); + expect(runner?.state.startedAt).toBe(8_000); + expect(runner?.state.executed).toBe(120); + expect(runner?.state.failed).toBe(1); + expect(runner?.state.total).toBe(500); + expect(runner?.state.percent).toBe(24); + expect(runner?.state.currentSQL).toContain('insert into demo'); + + now = 14_000; + await act(async () => { + resolveRun({ success: true, message: '执行完成' }); + await runPromise; + }); + + expect(runner?.state.status).toBe('done'); + expect(runner?.state.finishedAt).toBe(14_000); + expect(runner?.state.message).toBe('执行完成'); + }); + + it('marks the task cancelled when cancelExecution is requested', async () => { + renderRunner(); + + let resolveRun!: (value: { success: boolean; message: string }) => void; + const pendingRun = new Promise<{ success: boolean; message: string }>((resolve) => { + resolveRun = resolve; + }); + const cancelSpy = vi.fn(); + + let runPromise: Promise<{ success: boolean; message: string } | null> | null = null; + await act(async () => { + runPromise = runner?.runSQLFileExecutionWithProgress({ + title: 'seed.sql', + filePath: 'D:/sql/seed.sql', + run: async () => pendingRun, + cancel: async (jobId) => { + cancelSpy(jobId); + }, + }) || null; + await Promise.resolve(); + }); + + const jobId = runner?.state.jobId || ''; + await act(async () => { + await runner?.cancelExecution(); + }); + + expect(cancelSpy).toHaveBeenCalledWith(jobId); + expect(runner?.state.status).toBe('cancelled'); + + now = 6_000; + await act(async () => { + resolveRun({ success: false, message: '已取消' }); + await runPromise; + }); + + expect(runner?.state.status).toBe('cancelled'); + expect(runner?.state.finishedAt).toBe(6_000); + }); +}); diff --git a/frontend/src/components/useSQLFileExecutionRunner.ts b/frontend/src/components/useSQLFileExecutionRunner.ts new file mode 100644 index 00000000..1474eef8 --- /dev/null +++ b/frontend/src/components/useSQLFileExecutionRunner.ts @@ -0,0 +1,295 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { message } from 'antd'; + +import { EventsOn } from '../../wailsjs/runtime/runtime'; +import { t } from '../i18n'; + +export type SQLFileExecutionProgressEvent = { + jobId: string; + status?: 'running' | 'done' | 'cancelled' | 'error'; + executed?: number; + failed?: number; + total?: number; + percent?: number; + currentSQL?: string; + error?: string; +}; + +export type SQLFileExecutionRunnerStatus = + | 'idle' + | 'start' + | 'running' + | 'done' + | 'cancelled' + | 'error'; + +export type SQLFileExecutionState = { + jobId: string; + title: string; + filePath: string; + fileSizeMB: string; + startedAt: number; + finishedAt: number; + status: SQLFileExecutionRunnerStatus; + stage: string; + executed: number; + failed: number; + total: number; + percent: number; + currentSQL: string; + message: string; +}; + +export type SQLFileExecutionRunResult = { + success: boolean; + message: string; +}; + +export type RunSQLFileExecutionWithProgressOptions = { + title: string; + filePath: string; + fileSizeMB?: string; + run: (jobId: string) => Promise; + cancel?: (jobId: string) => void | Promise; +}; + +type UseSQLFileExecutionRunnerOptions = { + showToast?: boolean; +}; + +const createInitialState = (): SQLFileExecutionState => ({ + jobId: '', + title: '', + filePath: '', + fileSizeMB: '', + startedAt: 0, + finishedAt: 0, + status: 'idle', + stage: '', + executed: 0, + failed: 0, + total: 0, + percent: 0, + currentSQL: '', + message: '', +}); + +const normalizeCount = (value: unknown): number => { + const next = Number(value); + if (!Number.isFinite(next) || next < 0) { + return 0; + } + return Math.trunc(next); +}; + +const buildSQLFileExecutionJobId = (): string => + `sql-file-execution-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const EXECUTION_CANCELED_MESSAGE = '\u5df2\u53d6\u6d88'; + +export function useSQLFileExecutionRunner(options?: UseSQLFileExecutionRunnerOptions) { + const showToast = options?.showToast !== false; + const [state, setState] = useState(() => createInitialState()); + const activeJobIdRef = useRef(''); + const pendingEventRef = useRef(null); + const flushFrameRef = useRef(null); + const cancelRequestedRef = useRef(false); + const cancelHandlerRef = useRef<((jobId: string) => void | Promise) | null>(null); + + useEffect(() => { + const flushPendingEvent = () => { + flushFrameRef.current = null; + const event = pendingEventRef.current; + pendingEventRef.current = null; + if (!event || String(event.jobId || '') !== activeJobIdRef.current) { + return; + } + + setState((prev) => { + if (prev.jobId !== activeJobIdRef.current) { + return prev; + } + const nextStatus = (event.status || prev.status || 'running') as SQLFileExecutionRunnerStatus; + const nextStartedAt = prev.startedAt || Date.now(); + const isTerminal = nextStatus === 'done' || nextStatus === 'cancelled' || nextStatus === 'error'; + return { + ...prev, + startedAt: nextStartedAt, + finishedAt: isTerminal ? (prev.finishedAt || Date.now()) : prev.finishedAt, + status: nextStatus, + stage: nextStatus === 'cancelled' + ? t('sidebar.sql_file_exec.status.cancelled') + : nextStatus === 'error' + ? t('sidebar.sql_file_exec.status.error') + : t('sidebar.sql_file_exec.status.running'), + executed: normalizeCount(event.executed ?? prev.executed), + failed: normalizeCount(event.failed ?? prev.failed), + total: normalizeCount(event.total ?? prev.total), + percent: Math.max(0, Math.min(100, Number(event.percent ?? prev.percent) || 0)), + currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL, + message: typeof event.error === 'string' && event.error.trim() ? event.error : prev.message, + }; + }); + }; + + const scheduleFlush = () => { + if (flushFrameRef.current !== null) { + return; + } + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + flushFrameRef.current = window.requestAnimationFrame(flushPendingEvent); + return; + } + flushFrameRef.current = globalThis.setTimeout(flushPendingEvent, 16) as unknown as number; + }; + + const off = EventsOn('sqlfile:progress', (event: SQLFileExecutionProgressEvent) => { + if (!event || String(event.jobId || '') !== activeJobIdRef.current) { + return; + } + pendingEventRef.current = event; + scheduleFlush(); + }); + + return () => { + if (flushFrameRef.current !== null) { + if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(flushFrameRef.current); + } else { + globalThis.clearTimeout(flushFrameRef.current); + } + flushFrameRef.current = null; + } + pendingEventRef.current = null; + if (typeof off === 'function') { + off(); + } + }; + }, []); + + const reset = useCallback(() => { + activeJobIdRef.current = ''; + pendingEventRef.current = null; + cancelRequestedRef.current = false; + cancelHandlerRef.current = null; + setState(createInitialState()); + }, []); + + const cancelExecution = useCallback(async () => { + const jobId = activeJobIdRef.current; + if (!jobId || !cancelHandlerRef.current) { + return; + } + cancelRequestedRef.current = true; + await cancelHandlerRef.current(jobId); + setState((prev) => ( + prev.jobId !== jobId + ? prev + : { + ...prev, + status: 'cancelled', + stage: t('sidebar.sql_file_exec.status.cancelled'), + } + )); + }, []); + + const runSQLFileExecutionWithProgress = useCallback(async ( + runOptions: RunSQLFileExecutionWithProgressOptions, + ): Promise => { + if (state.status === 'start' || state.status === 'running') { + if (showToast) { + void message.warning(t('sidebar.sql_file_exec.message.already_running')); + } + return null; + } + + const jobId = buildSQLFileExecutionJobId(); + activeJobIdRef.current = jobId; + cancelRequestedRef.current = false; + cancelHandlerRef.current = runOptions.cancel || null; + setState({ + jobId, + title: String(runOptions.title || '').trim(), + filePath: String(runOptions.filePath || '').trim(), + fileSizeMB: String(runOptions.fileSizeMB || '').trim(), + startedAt: 0, + finishedAt: 0, + status: 'start', + stage: t('sidebar.sql_file_exec.workbench.stage.preparing'), + executed: 0, + failed: 0, + total: 0, + percent: 0, + currentSQL: '', + message: '', + }); + + try { + const result = await runOptions.run(jobId); + setState((prev) => { + if (prev.jobId !== jobId) { + return prev; + } + const canceled = cancelRequestedRef.current || prev.status === 'cancelled' || result.message === EXECUTION_CANCELED_MESSAGE; + const nextStatus: SQLFileExecutionRunnerStatus = canceled + ? 'cancelled' + : result.success + ? 'done' + : 'error'; + return { + ...prev, + startedAt: prev.startedAt || Date.now(), + finishedAt: prev.finishedAt || Date.now(), + status: nextStatus, + stage: nextStatus === 'cancelled' + ? t('sidebar.sql_file_exec.status.cancelled') + : nextStatus === 'done' + ? t('sidebar.sql_file_exec.status.done') + : t('sidebar.sql_file_exec.status.error'), + percent: nextStatus === 'cancelled' ? prev.percent : 100, + message: typeof result.message === 'string' ? result.message : prev.message, + }; + }); + + if (showToast) { + if (cancelRequestedRef.current || result.message === EXECUTION_CANCELED_MESSAGE) { + void message.info(t('sidebar.sql_file_exec.status.cancelled')); + } else if (result.success) { + void message.success(t('sidebar.sql_file_exec.status.done')); + } else { + void message.error(result.message || t('sidebar.sql_file_exec.status.error')); + } + } + return result; + } catch (error: any) { + const errorMessage = error?.message || String(error); + setState((prev) => { + if (prev.jobId !== jobId) { + return prev; + } + return { + ...prev, + startedAt: prev.startedAt || Date.now(), + finishedAt: prev.finishedAt || Date.now(), + status: cancelRequestedRef.current ? 'cancelled' : 'error', + stage: cancelRequestedRef.current + ? t('sidebar.sql_file_exec.status.cancelled') + : t('sidebar.sql_file_exec.status.error'), + message: errorMessage, + }; + }); + if (showToast) { + void message.error(errorMessage); + } + throw error; + } + }, [showToast, state.status]); + + return { + state, + reset, + cancelExecution, + runSQLFileExecutionWithProgress, + isRunning: state.status === 'start' || state.status === 'running', + }; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a4e19d4d..a87aa283 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -445,6 +445,7 @@ export interface TabData { | "query" | "table" | "design" + | "sql-file-execution" | "sql-analysis" | "redis-keys" | "redis-command" @@ -496,6 +497,8 @@ export interface TabData { tableExportInitialScope?: TableExportScope; tableExportQueryByScope?: Partial>; tableExportRowCountByScope?: Partial>; + sqlFileExecutionRequestKey?: string; + sqlFileExecutionFileSizeMB?: string; sqlAnalysisView?: "diagnose" | "slow-query"; sqlAnalysisRequestKey?: string; formatRestoreSnapshot?: { diff --git a/frontend/src/utils/sqlFileExecutionTab.test.ts b/frontend/src/utils/sqlFileExecutionTab.test.ts new file mode 100644 index 00000000..eaa2b1d6 --- /dev/null +++ b/frontend/src/utils/sqlFileExecutionTab.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildSQLFileExecutionWorkbenchTab, + resolveSQLFileExecutionWorkbenchTabId, +} from './sqlFileExecutionTab'; + +describe('sqlFileExecutionTab', () => { + it('builds stable workbench ids by connection, database, and normalized file path', () => { + expect(resolveSQLFileExecutionWorkbenchTabId('conn-1', 'demo', 'D:\\sql\\seed.sql')).toBe( + 'sql-file-execution-conn-1-demo-D:/sql/seed.sql', + ); + }); + + it('builds sql file execution workbench tabs with execution metadata', () => { + const tab = buildSQLFileExecutionWorkbenchTab({ + connectionId: 'conn-1', + dbName: 'demo', + filePath: 'D:\\sql\\seed.sql', + fileName: 'seed.sql', + fileSizeMB: '512.5', + requestKey: 'job-1', + }); + + expect(tab).toEqual(expect.objectContaining({ + id: 'sql-file-execution-conn-1-demo-D:/sql/seed.sql', + title: 'seed.sql', + type: 'sql-file-execution', + connectionId: 'conn-1', + dbName: 'demo', + filePath: 'D:/sql/seed.sql', + sqlFileExecutionFileSizeMB: '512.5', + sqlFileExecutionRequestKey: 'job-1', + })); + }); +}); diff --git a/frontend/src/utils/sqlFileExecutionTab.ts b/frontend/src/utils/sqlFileExecutionTab.ts new file mode 100644 index 00000000..a812e440 --- /dev/null +++ b/frontend/src/utils/sqlFileExecutionTab.ts @@ -0,0 +1,46 @@ +import type { TabData } from '../types'; +import { t } from '../i18n'; + +const normalizePathToken = (value: string): string => + value.replace(/\\/g, '/').trim(); + +export const resolveSQLFileExecutionWorkbenchTabId = ( + connectionId: string, + dbName: string | undefined, + filePath: string, +): string => { + const normalizedConnectionId = String(connectionId || '').trim() || 'none'; + const normalizedDbName = String(dbName || '').trim() || 'default'; + const normalizedFilePath = normalizePathToken(String(filePath || '')) || 'file'; + return `sql-file-execution-${normalizedConnectionId}-${normalizedDbName}-${normalizedFilePath}`; +}; + +type BuildSQLFileExecutionWorkbenchTabInput = { + connectionId: string; + dbName?: string; + filePath: string; + fileName?: string; + fileSizeMB?: string; + requestKey?: string; +}; + +export const buildSQLFileExecutionWorkbenchTab = ( + input: BuildSQLFileExecutionWorkbenchTabInput, +): TabData => { + const connectionId = String(input.connectionId || '').trim(); + const dbName = String(input.dbName || '').trim(); + const filePath = normalizePathToken(String(input.filePath || '')); + const fileName = String(input.fileName || '').trim(); + const defaultTitle = fileName || t('sidebar.sql_file_exec.title'); + + return { + id: resolveSQLFileExecutionWorkbenchTabId(connectionId, dbName || undefined, filePath), + title: defaultTitle, + type: 'sql-file-execution', + connectionId, + ...(dbName ? { dbName } : {}), + filePath, + sqlFileExecutionFileSizeMB: String(input.fileSizeMB || '').trim() || undefined, + sqlFileExecutionRequestKey: String(input.requestKey || `sql-file-execution-${Date.now()}`).trim(), + }; +}; diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 53f02a6d..109d8a43 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -706,10 +706,11 @@ func readSQLFileByPath(filePath string) connection.QueryResult { return readSQLFileByPathWithText(filePath, nil) } -func readSQLFileByPathWithText(filePath string, text fileBackendTextFunc) connection.QueryResult { +func resolveSQLFilePathInfoWithText(filePath string, text fileBackendTextFunc) (string, os.FileInfo, *connection.QueryResult) { selection := strings.TrimSpace(filePath) if selection == "" { - return connection.QueryResult{Success: false, Message: fileBackendText(text, "file.backend.error.file_path_required", nil)} + result := connection.QueryResult{Success: false, Message: fileBackendText(text, "file.backend.error.file_path_required", nil)} + return "", nil, &result } if abs, err := filepath.Abs(selection); err == nil { selection = abs @@ -721,22 +722,37 @@ func readSQLFileByPathWithText(filePath string, text fileBackendTextFunc) connec if os.IsNotExist(err) { data["errorCode"] = sqlFileErrorCodeNotFound } - return connection.QueryResult{Success: false, Message: fileBackendText(text, "file.backend.error.read_file_info_failed", map[string]any{"detail": err.Error()}), Data: data} + result := connection.QueryResult{Success: false, Message: fileBackendText(text, "file.backend.error.read_file_info_failed", map[string]any{"detail": err.Error()}), Data: data} + return "", nil, &result } if fi.IsDir() { - return connection.QueryResult{Success: false, Message: fileBackendText(text, "file.backend.error.selected_path_not_sql_file", nil)} + result := connection.QueryResult{Success: false, Message: fileBackendText(text, "file.backend.error.selected_path_not_sql_file", nil)} + return "", nil, &result + } + return selection, fi, nil +} + +func buildSQLFileSelectionMetadata(selection string, fileSize int64) map[string]interface{} { + return map[string]interface{}{ + "filePath": selection, + "name": filepath.Base(selection), + "fileSize": fileSize, + "fileSizeMB": fmt.Sprintf("%.1f", float64(fileSize)/(1024*1024)), + } +} + +func readSQLFileByPathWithText(filePath string, text fileBackendTextFunc) connection.QueryResult { + selection, fi, failed := resolveSQLFilePathInfoWithText(filePath, text) + if failed != nil { + return *failed } if fi.Size() > maxSQLFileSizeBytes { - sizeMB := float64(fi.Size()) / (1024 * 1024) + payload := buildSQLFileSelectionMetadata(selection, fi.Size()) + payload["isLargeFile"] = true return connection.QueryResult{ Success: true, - Data: map[string]interface{}{ - "isLargeFile": true, - "filePath": selection, - "fileSize": fi.Size(), - "fileSizeMB": fmt.Sprintf("%.1f", sizeMB), - }, + Data: payload, } } @@ -748,6 +764,17 @@ func readSQLFileByPathWithText(filePath string, text fileBackendTextFunc) connec return connection.QueryResult{Success: true, Data: string(content)} } +func selectSQLFileForExecutionByPathWithText(filePath string, text fileBackendTextFunc) connection.QueryResult { + selection, fi, failed := resolveSQLFilePathInfoWithText(filePath, text) + if failed != nil { + return *failed + } + return connection.QueryResult{ + Success: true, + Data: buildSQLFileSelectionMetadata(selection, fi.Size()), + } +} + func readSQLFileWithMetadataByPath(filePath string) connection.QueryResult { return readSQLFileWithMetadataByPathWithText(filePath, nil) } @@ -933,6 +960,32 @@ func (a *App) OpenSQLFile() connection.QueryResult { return readSQLFileWithMetadataByPathWithText(selection, a.appText) } +func (a *App) SelectSQLFileForExecution() connection.QueryResult { + selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: a.appText("file.backend.dialog.select_sql_file", nil), + Filters: []runtime.FileFilter{ + { + DisplayName: a.appText("file.backend.filter.sql_files", nil), + Pattern: "*.sql", + }, + { + DisplayName: a.appText("file.backend.filter.all_files_pattern", nil), + Pattern: "*.*", + }, + }, + }) + + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + if selection == "" { + return connection.QueryResult{Success: false, Message: "已取消"} + } + + return selectSQLFileForExecutionByPathWithText(selection, a.appText) +} + func (a *App) SelectSQLDirectory(currentDir string) connection.QueryResult { selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ Title: a.appText("file.backend.dialog.select_sql_directory", nil), @@ -1187,7 +1240,12 @@ func joinSQLFileBatchStatements(batch []sqlFilePendingStatement) string { if len(batch) == 0 { return "" } + totalLen := 0 + for _, item := range batch { + totalLen += len(item.SQL) + 2 + } var builder strings.Builder + builder.Grow(totalLen) for i, item := range batch { if i > 0 { builder.WriteString(";\n") diff --git a/internal/app/sql_split_stream.go b/internal/app/sql_split_stream.go index 5edf7d41..b2b4854d 100644 --- a/internal/app/sql_split_stream.go +++ b/internal/app/sql_split_stream.go @@ -1,6 +1,7 @@ package app import ( + "bufio" "io" "strings" ) @@ -400,11 +401,12 @@ func shouldDeferPLSQLKeywordPrefixInStream(text string, tokenStart int, tokenEnd // 返回总处理语句数和可能的错误。 func streamSQLFile(reader io.Reader, onStatement func(index int, stmt string) error) (int, error) { splitter := &sqlStreamSplitter{} - buffer := make([]byte, 256*1024) + bufferedReader := bufio.NewReaderSize(reader, 1024*1024) + buffer := make([]byte, 1024*1024) count := 0 for { - n, err := reader.Read(buffer) + n, err := bufferedReader.Read(buffer) if n > 0 { stmts := splitter.Feed(buffer[:n]) for _, stmt := range stmts { diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 43aa1dab..c55e0e85 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -7117,6 +7117,22 @@ "sidebar.sql_file_exec.status.error": "Fehler", "sidebar.sql_file_exec.status.running": "Wird ausgeführt", "sidebar.sql_file_exec.title": "Externe SQL-Datei ausführen", + "sidebar.sql_file_exec.message.already_running": "Eine externe SQL-Ausführung läuft bereits", + "sidebar.sql_file_exec.workbench.action.clear_history": "Verlauf leeren", + "sidebar.sql_file_exec.workbench.action.run_again": "Erneut ausführen", + "sidebar.sql_file_exec.workbench.description.current_task_empty": "Zurzeit läuft keine Ausführung. Nach der Dateiauswahl bleibt der Fortschritt hier sichtbar.", + "sidebar.sql_file_exec.workbench.empty.history": "Die letzten Ausführungen dieses Workbenches bleiben hier sichtbar, damit Dauer und Ergebnis nachvollziehbar bleiben.", + "sidebar.sql_file_exec.workbench.empty.not_started": "Nicht gestartet", + "sidebar.sql_file_exec.workbench.helper.auto_run": "Nach der Dateiauswahl wird derselbe Workbench wiederverwendet und zeigt den Ausführungsfortschritt dauerhaft an.", + "sidebar.sql_file_exec.workbench.helper.reuse": "Dieselbe Verbindung, Datenbank und Datei verwenden diesen Workbench erneut für Wiederholungen und Ergebnisvergleich.", + "sidebar.sql_file_exec.workbench.label.current_sql": "Aktuelle SQL", + "sidebar.sql_file_exec.workbench.label.current_stage": "Aktuelle Phase", + "sidebar.sql_file_exec.workbench.label.elapsed": "Dauer", + "sidebar.sql_file_exec.workbench.label.file_path": "Dateipfad", + "sidebar.sql_file_exec.workbench.label.progress_summary": "Ausführungsübersicht", + "sidebar.sql_file_exec.workbench.label.started_at": "Gestartet um", + "sidebar.sql_file_exec.workbench.section.config": "Ausführungskonfiguration", + "sidebar.sql_file_exec.workbench.stage.preparing": "Ausführung wird vorbereitet", "sidebar.sql_file.default_name": "SQL-Datei", "sidebar.sql_template.duckdb_macro_hint": "Verwenden Sie SQL Macro für funktionsähnliches Verhalten", "sidebar.sql_template.duckdb_procedure_unsupported": "DuckDB unterstützt gespeicherte Prozeduren noch nicht", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 119eb07d..d2530992 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -7117,6 +7117,22 @@ "sidebar.sql_file_exec.status.error": "Error", "sidebar.sql_file_exec.status.running": "Running", "sidebar.sql_file_exec.title": "Run external SQL file", + "sidebar.sql_file_exec.message.already_running": "An external SQL execution task is already running", + "sidebar.sql_file_exec.workbench.action.clear_history": "Clear history", + "sidebar.sql_file_exec.workbench.action.run_again": "Run again", + "sidebar.sql_file_exec.workbench.description.current_task_empty": "No execution is running yet. Once a file is selected, progress will stay visible here.", + "sidebar.sql_file_exec.workbench.empty.history": "Recent executions for this workbench stay here so you can review elapsed time and results.", + "sidebar.sql_file_exec.workbench.empty.not_started": "Not started", + "sidebar.sql_file_exec.workbench.helper.auto_run": "After selecting a file, the current workbench is reused and keeps the execution progress visible here.", + "sidebar.sql_file_exec.workbench.helper.reuse": "The same connection, database, and file reuse this workbench for repeat runs and result comparison.", + "sidebar.sql_file_exec.workbench.label.current_sql": "Current SQL", + "sidebar.sql_file_exec.workbench.label.current_stage": "Current stage", + "sidebar.sql_file_exec.workbench.label.elapsed": "Elapsed", + "sidebar.sql_file_exec.workbench.label.file_path": "File path", + "sidebar.sql_file_exec.workbench.label.progress_summary": "Execution summary", + "sidebar.sql_file_exec.workbench.label.started_at": "Started at", + "sidebar.sql_file_exec.workbench.section.config": "Execution config", + "sidebar.sql_file_exec.workbench.stage.preparing": "Preparing execution", "sidebar.sql_file.default_name": "SQL file", "sidebar.sql_template.duckdb_macro_hint": "Use SQL Macro for function-like behavior", "sidebar.sql_template.duckdb_procedure_unsupported": "DuckDB does not support stored procedures yet", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index fd8ae9c2..33bf1b23 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -7117,6 +7117,22 @@ "sidebar.sql_file_exec.status.error": "エラー", "sidebar.sql_file_exec.status.running": "実行中", "sidebar.sql_file_exec.title": "外部 SQL ファイルを実行", + "sidebar.sql_file_exec.message.already_running": "外部 SQL の実行タスクはすでに実行中です", + "sidebar.sql_file_exec.workbench.action.clear_history": "履歴をクリア", + "sidebar.sql_file_exec.workbench.action.run_again": "再実行", + "sidebar.sql_file_exec.workbench.description.current_task_empty": "まだ実行中のタスクはありません。ファイルを選択すると、進捗がここに表示され続けます。", + "sidebar.sql_file_exec.workbench.empty.history": "このワークベンチの最近の実行はここに残るため、所要時間と結果をすぐに確認できます。", + "sidebar.sql_file_exec.workbench.empty.not_started": "未開始", + "sidebar.sql_file_exec.workbench.helper.auto_run": "ファイル選択後は現在のワークベンチを再利用し、実行進捗をここに継続表示します。", + "sidebar.sql_file_exec.workbench.helper.reuse": "同じ接続、データベース、ファイルではこのワークベンチを再利用し、再実行や結果比較をしやすくします。", + "sidebar.sql_file_exec.workbench.label.current_sql": "現在の SQL", + "sidebar.sql_file_exec.workbench.label.current_stage": "現在の段階", + "sidebar.sql_file_exec.workbench.label.elapsed": "経過時間", + "sidebar.sql_file_exec.workbench.label.file_path": "ファイルパス", + "sidebar.sql_file_exec.workbench.label.progress_summary": "実行サマリー", + "sidebar.sql_file_exec.workbench.label.started_at": "開始時刻", + "sidebar.sql_file_exec.workbench.section.config": "実行設定", + "sidebar.sql_file_exec.workbench.stage.preparing": "実行を準備中", "sidebar.sql_file.default_name": "SQL ファイル", "sidebar.sql_template.duckdb_macro_hint": "関数のような動作には SQL Macro を使用してください", "sidebar.sql_template.duckdb_procedure_unsupported": "DuckDB はまだストアドプロシージャをサポートしていません", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index a05440cf..fdc2299e 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -7117,6 +7117,22 @@ "sidebar.sql_file_exec.status.error": "Ошибка", "sidebar.sql_file_exec.status.running": "Выполняется", "sidebar.sql_file_exec.title": "Запустить внешний SQL-файл", + "sidebar.sql_file_exec.message.already_running": "Задача выполнения внешнего SQL уже запущена", + "sidebar.sql_file_exec.workbench.action.clear_history": "Очистить историю", + "sidebar.sql_file_exec.workbench.action.run_again": "Запустить снова", + "sidebar.sql_file_exec.workbench.description.current_task_empty": "Сейчас нет активного запуска. После выбора файла прогресс будет постоянно отображаться здесь.", + "sidebar.sql_file_exec.workbench.empty.history": "Последние запуски этого рабочего стола сохраняются здесь, чтобы можно было быстро посмотреть длительность и результат.", + "sidebar.sql_file_exec.workbench.empty.not_started": "Не запущено", + "sidebar.sql_file_exec.workbench.helper.auto_run": "После выбора файла текущий рабочий стол будет переиспользован и продолжит показывать прогресс выполнения.", + "sidebar.sql_file_exec.workbench.helper.reuse": "Одинаковые подключение, база и файл переиспользуют этот рабочий стол для повторных запусков и сравнения результатов.", + "sidebar.sql_file_exec.workbench.label.current_sql": "Текущий SQL", + "sidebar.sql_file_exec.workbench.label.current_stage": "Текущий этап", + "sidebar.sql_file_exec.workbench.label.elapsed": "Длительность", + "sidebar.sql_file_exec.workbench.label.file_path": "Путь к файлу", + "sidebar.sql_file_exec.workbench.label.progress_summary": "Сводка выполнения", + "sidebar.sql_file_exec.workbench.label.started_at": "Время старта", + "sidebar.sql_file_exec.workbench.section.config": "Параметры выполнения", + "sidebar.sql_file_exec.workbench.stage.preparing": "Подготовка к выполнению", "sidebar.sql_file.default_name": "SQL-файл", "sidebar.sql_template.duckdb_macro_hint": "Используйте SQL Macro для поведения, похожего на функцию", "sidebar.sql_template.duckdb_procedure_unsupported": "DuckDB пока не поддерживает хранимые процедуры", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index ade85261..225244df 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -7117,6 +7117,22 @@ "sidebar.sql_file_exec.status.error": "错误", "sidebar.sql_file_exec.status.running": "运行中", "sidebar.sql_file_exec.title": "运行外部 SQL 文件", + "sidebar.sql_file_exec.message.already_running": "当前已有外部 SQL 执行任务在运行", + "sidebar.sql_file_exec.workbench.action.clear_history": "清空历史", + "sidebar.sql_file_exec.workbench.action.run_again": "重新执行", + "sidebar.sql_file_exec.workbench.description.current_task_empty": "当前还没有运行任务,选择文件后会在这里持续展示执行进度。", + "sidebar.sql_file_exec.workbench.empty.history": "同一工作台的最近执行会保留在这里,方便回看耗时和结果。", + "sidebar.sql_file_exec.workbench.empty.not_started": "未开始", + "sidebar.sql_file_exec.workbench.helper.auto_run": "文件选定后会复用当前工作台,并在这里持续展示执行进度。", + "sidebar.sql_file_exec.workbench.helper.reuse": "同一连接、库和文件会复用当前工作台,便于重复执行和对比结果。", + "sidebar.sql_file_exec.workbench.label.current_sql": "当前 SQL", + "sidebar.sql_file_exec.workbench.label.current_stage": "当前阶段", + "sidebar.sql_file_exec.workbench.label.elapsed": "耗时", + "sidebar.sql_file_exec.workbench.label.file_path": "文件路径", + "sidebar.sql_file_exec.workbench.label.progress_summary": "执行摘要", + "sidebar.sql_file_exec.workbench.label.started_at": "开始时间", + "sidebar.sql_file_exec.workbench.section.config": "执行配置", + "sidebar.sql_file_exec.workbench.stage.preparing": "准备执行", "sidebar.sql_file.default_name": "SQL 文件", "sidebar.sql_template.duckdb_macro_hint": "使用 SQL Macro 实现类似函数的行为", "sidebar.sql_template.duckdb_procedure_unsupported": "DuckDB 暂不支持存储过程", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index fce55f2d..24bf58ae 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -7117,6 +7117,22 @@ "sidebar.sql_file_exec.status.error": "錯誤", "sidebar.sql_file_exec.status.running": "執行中", "sidebar.sql_file_exec.title": "執行外部 SQL 檔案", + "sidebar.sql_file_exec.message.already_running": "目前已有外部 SQL 執行任務在進行中", + "sidebar.sql_file_exec.workbench.action.clear_history": "清空歷史", + "sidebar.sql_file_exec.workbench.action.run_again": "重新執行", + "sidebar.sql_file_exec.workbench.description.current_task_empty": "目前尚未執行任務,選取檔案後會在這裡持續顯示進度。", + "sidebar.sql_file_exec.workbench.empty.history": "同一工作台的最近執行會保留在這裡,方便回看耗時與結果。", + "sidebar.sql_file_exec.workbench.empty.not_started": "尚未開始", + "sidebar.sql_file_exec.workbench.helper.auto_run": "選取檔案後會重用目前工作台,並在這裡持續顯示執行進度。", + "sidebar.sql_file_exec.workbench.helper.reuse": "相同連線、資料庫與檔案會重用這個工作台,方便重複執行與比較結果。", + "sidebar.sql_file_exec.workbench.label.current_sql": "目前 SQL", + "sidebar.sql_file_exec.workbench.label.current_stage": "目前階段", + "sidebar.sql_file_exec.workbench.label.elapsed": "耗時", + "sidebar.sql_file_exec.workbench.label.file_path": "檔案路徑", + "sidebar.sql_file_exec.workbench.label.progress_summary": "執行摘要", + "sidebar.sql_file_exec.workbench.label.started_at": "開始時間", + "sidebar.sql_file_exec.workbench.section.config": "執行設定", + "sidebar.sql_file_exec.workbench.stage.preparing": "準備執行", "sidebar.sql_file.default_name": "SQL 檔案", "sidebar.sql_template.duckdb_macro_hint": "使用 SQL Macro 實作類似函式的行為", "sidebar.sql_template.duckdb_procedure_unsupported": "DuckDB 尚不支援預存程序",