From 3ff5141184d688bf85414a524ebb3097af245ebd Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 19 Jun 2026 17:13:24 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sidebar):=20?= =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=A4=96=E9=83=A8=20SQL=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sidebar.locate-toolbar.test.tsx | 3 +- frontend/src/components/Sidebar.tsx | 726 ++-------------- ...idebarExternalSqlOpenFeedback.i18n.test.ts | 2 +- .../sidebar/SidebarExternalSqlWorkflow.tsx | 817 ++++++++++++++++++ 4 files changed, 871 insertions(+), 677 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 95c7055..045852d 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -67,6 +67,7 @@ const readSidebarSource = () => [ readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'), readSourceFile('./sidebar/sidebarMetadataLoaders.ts'), readSourceFile('./sidebar/useSidebarBatchExport.ts'), + readSourceFile('./sidebar/SidebarExternalSqlWorkflow.tsx'), readSourceFile('./sidebarV2Utils.ts'), ].join('\n'); const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx'); @@ -2335,7 +2336,7 @@ describe('Sidebar locate toolbar', () => { const externalSqlReadEnd = source.indexOf('const externalSQLTrees = externalSQLDirectoryResults.reduce', externalSqlReadStart); const externalSqlReadSource = source.slice(externalSqlReadStart, externalSqlReadEnd); const externalSqlFlowStart = source.indexOf('const handleAddExternalSQLDirectory = async (node: any) => {'); - const externalSqlFlowEnd = source.indexOf('const handleCreateDatabase = async () => {', externalSqlFlowStart); + const externalSqlFlowEnd = source.indexOf('const cancelSQLFileExecution = () => {', externalSqlFlowStart); const externalSqlFlowSource = source.slice(externalSqlFlowStart, externalSqlFlowEnd); const treeTitleStart = source.indexOf('const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => {'); const treeTitleEnd = source.indexOf('const selectConnectionFromRail', treeTitleStart); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0c4802b..dcadb91 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -31,6 +31,19 @@ import { type BatchObjectFilterType, type BatchSelectionScope, } from './sidebar/useSidebarBatchExport'; +import { + ExternalSQLFileModal, + SQLFileExecutionModal, + useSidebarExternalSqlWorkflow, +} from './sidebar/SidebarExternalSqlWorkflow'; +export { + buildSQLFileExecutionFooter, + SQLFileExecutionProgressContent, +} from './sidebar/SidebarExternalSqlWorkflow'; +export type { + SQLFileExecutionProgressState, + SQLFileExecutionStatus, +} from './sidebar/SidebarExternalSqlWorkflow'; import { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, @@ -61,7 +74,7 @@ export { } from './sidebar/sidebarHelpers'; import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; -import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; +import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Switch } from 'antd'; import { DatabaseOutlined, TableOutlined, @@ -115,7 +128,7 @@ import { import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; - import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; + import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; @@ -149,7 +162,7 @@ import { } from '../utils/tableExportTab'; import { useExportProgressDialog } from './ExportProgressModal'; import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; -import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; +import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; import { getCurrentLanguage, t } from '../i18n'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition'; @@ -158,11 +171,9 @@ import MessagePublishModal from './MessagePublishModal'; import { SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, - isExternalSQLDirectoryModalMode, normalizeMySQLViewDDLForEditing, resolveSidebarContextMenuPosition, resolveSidebarObjectDragText, - type ExternalSQLFileModalMode, type SearchScope, } from './sidebarCoreUtils'; export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils'; @@ -254,97 +265,6 @@ const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; // resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers -export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; - -export type SQLFileExecutionProgressState = { - fileSizeMB: string; - status: SQLFileExecutionStatus; - executed: number; - failed: number; - percent: number; - currentSQL: string; - resultMessage: string; -}; - -const resolveSQLFileExecutionStatusLabel = (status: SQLFileExecutionStatus): string => { - switch (status) { - case 'done': - return `✅ ${t('sidebar.sql_file_exec.status.done')}`; - case 'cancelled': - return `⚠️ ${t('sidebar.sql_file_exec.status.cancelled')}`; - case 'error': - return `❌ ${t('sidebar.sql_file_exec.status.error')}`; - case 'running': - default: - return t('sidebar.sql_file_exec.status.running'); - } -}; - -export const buildSQLFileExecutionFooter = ({ - status, - onCancelExecution, - onClose, -}: { - status: SQLFileExecutionStatus; - onCancelExecution: () => void; - onClose: () => void; -}): React.ReactNode[] => { - if (status === 'running') { - return [ - , - ]; - } - - return [ - , - ]; -}; - -export const SQLFileExecutionProgressContent: React.FC = ({ - fileSizeMB, - status, - executed, - failed, - percent, - currentSQL, - resultMessage, -}) => ( - <> -
- -
-
-
{t('sidebar.sql_file_exec.file_size')}{fileSizeMB} MB
-
{t('sidebar.sql_file_exec.status_label')}{resolveSQLFileExecutionStatusLabel(status)}
-
- {t('sidebar.sql_file_exec.executed_label')} - {executed} - {t('sidebar.sql_file_exec.rows_separator')} - 0 ? '#ff4d4f' : undefined }}>{failed} - {t('sidebar.sql_file_exec.rows_suffix')} -
-
- {currentSQL && status === 'running' && ( -
- {currentSQL} -
- )} - {resultMessage && status !== 'running' && ( -
- {resultMessage} -
- )} - -); - // shouldLoadSidebarNodeOnExpand 已迁移到 ./sidebar/sidebarHelpers // resolveSidebarTableNameForCopy 已迁移到 ./sidebar/sidebarHelpers @@ -946,10 +866,6 @@ const Sidebar: React.FC<{ const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false); const [renameSavedQueryForm] = Form.useForm(); const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState(null); - const [isExternalSQLFileModalOpen, setIsExternalSQLFileModalOpen] = useState(false); - const [externalSQLFileForm] = Form.useForm(); - const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState('create'); - const [externalSQLFileTarget, setExternalSQLFileTarget] = useState(null); // Connection Tag Modals const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); const [createTagForm] = Form.useForm(); @@ -1306,6 +1222,36 @@ const Sidebar: React.FC<{ void refreshGlobalExternalSQLRootNode(false); }, [refreshGlobalExternalSQLRootNode]); + const { + handleRunSQLFile, + handleOpenSQLFileFromToolbar, + openExternalSQLFile, + openCreateExternalSQLFileModal, + openRenameExternalSQLFileModal, + openCreateExternalSQLDirectoryModal, + openRenameExternalSQLDirectoryModal, + handleDeleteExternalSQLFile, + handleDeleteExternalSQLDirectory, + handleAddExternalSQLDirectory, + handleRemoveExternalSQLDirectory, + handleRefreshExternalSQLDirectory, + externalSQLFileModalProps, + sqlFileExecutionModalProps, + } = useSidebarExternalSqlWorkflow({ + connections, + externalSQLDirectories, + activeTab, + connectionIds, + selectedNodesRef, + addTab, + saveExternalSQLDirectory, + deleteExternalSQLDirectory, + refreshGlobalExternalSQLRootNode, + setExpandedKeys, + setAutoExpandParent, + getActiveContext: () => useStore.getState().activeContext, + }); + const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => { if (!node) return null; if (node.type === 'database') { @@ -2649,130 +2595,6 @@ const Sidebar: React.FC<{ }, wasClosed ? 350 : 0); }; - const handleRunSQLFile = async (node: any) => { - const res = await OpenSQLFile(); - 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 || ''); - 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', - connectionId, - dbName: dbName, - query: data.content, - filePath: data.filePath || undefined, - }); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.read_file_failed', { error: res.message })); - } - }; - - const handleOpenSQLFileFromToolbar = async () => { - const ctx = useStore.getState().activeContext; - if (!ctx?.connectionId) { - message.warning(t('sidebar.message.select_connection_or_database_first')); - return; - } - const res = await OpenSQLFile(); - 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 || ''); - 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', - connectionId: ctx.connectionId, - dbName: ctx.dbName || undefined, - query: data.content, - filePath: data.filePath || undefined, - }); - } else if (res.message !== '已取消') { - message.error(t('sidebar.message.read_file_failed', { error: res.message })); - } - }; - - // SQL 文件流式执行状态 - const [sqlFileExecState, setSqlFileExecState] = useState<{ - open: boolean; - jobId: string; - fileSizeMB: string; - status: SQLFileExecutionStatus; - executed: number; - failed: number; - total: number; - percent: number; - currentSQL: string; - resultMessage: string; - }>({ - open: false, jobId: '', fileSizeMB: '', status: 'running', - executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: '' - }); - - 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 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 refreshDatabaseNode = async (dbNodeKey: string) => { if (!dbNodeKey) { return; @@ -2783,384 +2605,6 @@ const Sidebar: React.FC<{ } }; - const normalizeExternalSQLFileName = (rawName: unknown): string => { - const name = String(rawName || '').trim(); - if (!name) return ''; - return /\.sql$/i.test(name) ? name : `${name}.sql`; - }; - - const normalizeExternalSQLDirectoryName = (rawName: unknown): string => { - return String(rawName || '').trim(); - }; - - const getExternalSQLParentDirectoryPath = (node: any): string => { - const path = String(node?.dataRef?.path || '').trim(); - if (node?.type === 'external-sql-directory' || node?.type === 'external-sql-folder') { - return path; - } - if (node?.type === 'external-sql-file') { - const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); - return index > 0 ? path.slice(0, index) : ''; - } - return ''; - }; - - const resolveExternalSQLExecutionContext = (): { connectionId: string; dbName: string } => { - const activeStoreContext = useStore.getState().activeContext; - const selectedConnectionId = selectedNodesRef.current - .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) - .find(Boolean) || ''; - return { - connectionId: String( - activeStoreContext?.connectionId - || activeTab?.connectionId - || selectedConnectionId - || '', - ).trim(), - dbName: String( - activeStoreContext?.dbName - || activeTab?.dbName - || '', - ).trim(), - }; - }; - - const normalizeSQLFileDialogData = (data: unknown): { content: string; filePath: string; fileName: string; isLargeFile: boolean; fileSizeMB?: string } => { - if (data && typeof data === 'object') { - const payload = data as Record; - const filePath = String(payload.filePath || '').trim(); - return { - content: String(payload.content ?? ''), - filePath, - fileName: String(payload.name || filePath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_file_exec.title')).trim(), - isLargeFile: payload.isLargeFile === true, - fileSizeMB: String(payload.fileSizeMB || '').trim() || undefined, - }; - } - return { - content: String(data || ''), - filePath: '', - fileName: t('sidebar.sql_file_exec.title'), - isLargeFile: false, - }; - }; - - const openExternalSQLFile = async (fileNode: any) => { - const fileContext = { - connectionId: String(fileNode?.dataRef?.connectionId || '').trim(), - dbName: String(fileNode?.dataRef?.dbName || '').trim(), - }; - const fallbackContext = resolveExternalSQLExecutionContext(); - const connectionId = fileContext.connectionId || fallbackContext.connectionId; - const dbName = fileContext.dbName || fallbackContext.dbName; - const filePath = String(fileNode?.dataRef?.path || '').trim(); - const fileName = String(fileNode?.dataRef?.name || fileNode?.title || t('sidebar.sql_file.default_name')).trim() || t('sidebar.sql_file.default_name'); - if (!filePath) { - message.error(t('sidebar.message.sql_file_path_incomplete')); - return; - } - - const res = await ReadSQLFile(filePath); - if (!res.success) { - if (res.message !== '已取消') { - message.error(t('sidebar.message.read_sql_file_failed', { error: res.message })); - } - return; - } - - const data = res.data; - if (data && typeof data === 'object' && data.isLargeFile) { - if (!connectionId) { - 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); - return; - } - - addTab({ - id: buildExternalSQLTabId(connectionId, dbName, filePath), - title: fileName, - type: 'query', - connectionId, - dbName: dbName || undefined, - query: String(data || ''), - filePath, - }); - }; - - const openCreateExternalSQLFileModal = (node: any) => { - const directoryPath = getExternalSQLParentDirectoryPath(node); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_file_parent_missing')); - return; - } - setExternalSQLFileModalMode('create'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: 'new-query.sql' }); - setIsExternalSQLFileModalOpen(true); - }; - - const openRenameExternalSQLFileModal = (node: any) => { - const currentName = String(node?.dataRef?.name || node?.title || '').trim(); - if (!currentName) { - message.error(t('sidebar.message.external_sql_file_rename_target_missing')); - return; - } - setExternalSQLFileModalMode('rename'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: currentName }); - setIsExternalSQLFileModalOpen(true); - }; - - const openCreateExternalSQLDirectoryModal = (node: any) => { - const directoryPath = getExternalSQLParentDirectoryPath(node); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_parent_missing')); - return; - } - setExternalSQLFileModalMode('create-directory'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: 'new-folder' }); - setIsExternalSQLFileModalOpen(true); - }; - - const openRenameExternalSQLDirectoryModal = (node: any) => { - const currentName = String(node?.dataRef?.name || node?.title || '').trim(); - if (!currentName) { - message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); - return; - } - setExternalSQLFileModalMode('rename-directory'); - setExternalSQLFileTarget(node); - externalSQLFileForm.setFieldsValue({ name: currentName }); - setIsExternalSQLFileModalOpen(true); - }; - - const handleExternalSQLFileModalOk = async () => { - try { - const values = await externalSQLFileForm.validateFields(); - const isDirectoryMode = isExternalSQLDirectoryModalMode(externalSQLFileModalMode); - const name = isDirectoryMode - ? normalizeExternalSQLDirectoryName(values.name) - : normalizeExternalSQLFileName(values.name); - if (!name) { - message.error(t(isDirectoryMode ? 'sidebar.message.sql_directory_name_required' : 'sidebar.message.sql_file_name_required')); - return; - } - - if (externalSQLFileModalMode === 'create') { - const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_file_parent_missing')); - return; - } - const res = await CreateSQLFile(directoryPath, name); - if (!res.success) { - message.error(t('sidebar.message.create_sql_file_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_file_created')); - } else if (externalSQLFileModalMode === 'rename') { - const filePath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); - if (!filePath) { - message.error(t('sidebar.message.external_sql_file_rename_target_missing')); - return; - } - const res = await RenameSQLFile(filePath, name); - if (!res.success) { - message.error(t('sidebar.message.rename_sql_file_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_file_renamed')); - } else if (externalSQLFileModalMode === 'create-directory') { - const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_parent_missing')); - return; - } - const res = await CreateSQLDirectory(directoryPath, name); - if (!res.success) { - message.error(t('sidebar.message.create_sql_directory_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_directory_created')); - } else { - const directoryPath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); - return; - } - const res = await RenameSQLDirectory(directoryPath, name); - if (!res.success) { - message.error(t('sidebar.message.rename_sql_directory_failed', { error: res.message })); - return; - } - - if (externalSQLFileTarget?.type === 'external-sql-directory') { - const payload = (res.data && typeof res.data === 'object') ? res.data as Record : {}; - const nextPath = String(payload.directoryPath || payload.path || '').trim(); - const nextName = String(payload.name || name).trim(); - const oldDirectoryId = String(externalSQLFileTarget?.dataRef?.id || '').trim(); - if (!nextPath || !oldDirectoryId) { - message.error(t('sidebar.message.external_sql_directory_rename_sync_failed')); - await refreshGlobalExternalSQLRootNode(false); - return; - } - const nextDirectory: ExternalSQLDirectory = { - id: buildExternalSQLDirectoryId('', '', nextPath), - name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), - path: nextPath, - createdAt: Number(externalSQLFileTarget?.dataRef?.createdAt) || Date.now(), - }; - deleteExternalSQLDirectory(oldDirectoryId); - saveExternalSQLDirectory(nextDirectory); - const nextDirectories = [ - ...externalSQLDirectories.filter((item) => item.id !== oldDirectoryId), - nextDirectory, - ]; - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - } else { - await refreshGlobalExternalSQLRootNode(false); - } - message.success(t('sidebar.message.sql_directory_renamed')); - } - - setIsExternalSQLFileModalOpen(false); - setExternalSQLFileTarget(null); - externalSQLFileForm.resetFields(); - } catch { - // Validate failed - } - }; - - const handleDeleteExternalSQLFile = (node: any) => { - const filePath = String(node?.dataRef?.path || '').trim(); - const fileName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_file.default_name')).trim(); - if (!filePath) { - message.error(t('sidebar.message.external_sql_file_delete_target_missing')); - return; - } - - Modal.confirm({ - title: t('sidebar.modal.confirm_delete_sql_file.title'), - content: t('sidebar.modal.confirm_delete_sql_file.content', { name: fileName }), - okText: t('sidebar.action.delete'), - cancelText: t('sidebar.action.cancel'), - okButtonProps: { danger: true }, - onOk: async () => { - const res = await DeleteSQLFile(filePath); - if (!res.success) { - message.error(t('sidebar.message.delete_sql_file_failed', { error: res.message })); - return; - } - await refreshGlobalExternalSQLRootNode(false); - message.success(t('sidebar.message.sql_file_deleted')); - }, - }); - }; - - const handleDeleteExternalSQLDirectory = (node: any) => { - const directoryPath = String(node?.dataRef?.path || '').trim(); - const directoryName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_directory.default_name')).trim(); - if (!directoryPath) { - message.error(t('sidebar.message.external_sql_directory_delete_target_missing')); - return; - } - - Modal.confirm({ - title: t('sidebar.modal.confirm_delete_sql_directory.title'), - content: t('sidebar.modal.confirm_delete_sql_directory.content', { name: directoryName }), - okText: t('sidebar.action.delete'), - cancelText: t('sidebar.action.cancel'), - okButtonProps: { danger: true }, - onOk: async () => { - const res = await DeleteSQLDirectory(directoryPath); - if (!res.success) { - message.error(t('sidebar.message.delete_sql_directory_failed', { error: res.message })); - return; - } - - if (node?.type === 'external-sql-directory') { - const directoryId = String(node?.dataRef?.id || '').trim(); - if (directoryId) { - deleteExternalSQLDirectory(directoryId); - const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - } else { - await refreshGlobalExternalSQLRootNode(false); - } - } else { - await refreshGlobalExternalSQLRootNode(false); - } - message.success(t('sidebar.message.sql_directory_deleted')); - }, - }); - }; - - const handleAddExternalSQLDirectory = async (node: any) => { - const currentDirectory = externalSQLDirectories[0]?.path || ''; - const selection = await SelectSQLDirectory(currentDirectory); - if (!selection.success) { - if (selection.message !== '已取消') { - message.error(t('sidebar.message.select_sql_directory_failed', { error: selection.message })); - } - return; - } - - const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record : {}; - const path = String(payload.path || '').trim(); - const name = String(payload.name || '').trim(); - if (!path) { - message.error(t('sidebar.message.sql_directory_path_invalid')); - return; - } - - const directoryId = buildExternalSQLDirectoryId('', '', path); - const nextDirectory: ExternalSQLDirectory = { - id: directoryId, - name: name || path.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), - path, - createdAt: Date.now(), - }; - saveExternalSQLDirectory(nextDirectory); - - const nextDirectories = [ - ...externalSQLDirectories.filter((item) => item.path.replace(/\\/g, '/').toLowerCase() !== path.replace(/\\/g, '/').toLowerCase()), - nextDirectory, - ]; - setExpandedKeys((prev) => Array.from(new Set([...prev, 'external-sql-root']))); - setAutoExpandParent(false); - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - message.success(t('sidebar.message.external_sql_directory_added')); - }; - - const handleRemoveExternalSQLDirectory = async (node: any) => { - const directoryId = String(node?.dataRef?.id || '').trim(); - if (!directoryId) { - message.error(t('sidebar.message.external_sql_directory_not_found')); - return; - } - deleteExternalSQLDirectory(directoryId); - const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); - await refreshGlobalExternalSQLRootNode(false, nextDirectories); - message.success(t('sidebar.message.external_sql_directory_removed')); - }; - - const handleRefreshExternalSQLDirectory = async (node: any) => { - void node; - await refreshGlobalExternalSQLRootNode(true); - message.success(t('sidebar.message.external_sql_directory_refreshed')); - }; - const handleCreateDatabase = async () => { try { const values = await createDbForm.validateFields(); @@ -6733,48 +6177,7 @@ const Sidebar: React.FC<{ - { - setIsExternalSQLFileModalOpen(false); - setExternalSQLFileTarget(null); - externalSQLFileForm.resetFields(); - }} - okText={t(externalSQLFileModalMode === 'create' || externalSQLFileModalMode === 'create-directory' ? 'sidebar.external_sql_modal.action.create' : 'sidebar.external_sql_modal.action.rename')} - cancelText={t('common.cancel')} - > -
- { - const name = String(value || '').trim(); - if (!name) return; - if (/[\\/]/.test(name) || name === '.' || name === '..') { - throw new Error(isExternalSQLDirectoryModalMode(externalSQLFileModalMode) ? t('sidebar.external_sql_modal.validation.directory_name_no_separator') : t('sidebar.external_sql_modal.validation.sql_file_name_no_separator')); - } - }, - }, - ]} - extra={isExternalSQLDirectoryModalMode(externalSQLFileModalMode) ? t('sidebar.external_sql_modal.help.directory') : t('sidebar.external_sql_modal.help.sql_file')} - > - - -
-
+ , "批量操作表", "按对象批量导出结构、数据或完整备份。")} @@ -7072,38 +6475,11 @@ const Sidebar: React.FC<{ )} - {/* SQL 文件流式执行进度 Modal */} - { - CancelSQLFileExecution(sqlFileExecState.jobId); - setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' })); - }, - onClose: () => setSqlFileExecState(prev => ({ ...prev, open: false })), - })} - onCancel={() => { - if (sqlFileExecState.status !== 'running') { - setSqlFileExecState(prev => ({ ...prev, open: false })); - } - }} - styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} - > - - + modalPanelStyle={modalPanelStyle} + {...sqlFileExecutionModalProps} + /> setFindInDbContext({ open: false, connectionId: '', dbName: '' })} diff --git a/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts b/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts index 0a597d4..38e285d 100644 --- a/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts +++ b/frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const source = readFileSync(new URL('./sidebar/SidebarExternalSqlWorkflow.tsx', import.meta.url), 'utf8'); const externalSqlOpenBlock = source.slice( source.indexOf('const normalizeSQLFileDialogData ='), diff --git a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx new file mode 100644 index 0000000..9c45a6e --- /dev/null +++ b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx @@ -0,0 +1,817 @@ +import React, { 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 { t } from '../../i18n'; +import { resolveSidebarNodeConnectionId } from '../sidebarV2Utils'; +import { + isExternalSQLDirectoryModalMode, + type ExternalSQLFileModalMode, +} from '../sidebarCoreUtils'; +import { + OpenSQLFile, + ExecuteSQLFile, + CancelSQLFileExecution, + SelectSQLDirectory, + ReadSQLFile, + CreateSQLFile, + CreateSQLDirectory, + DeleteSQLFile, + DeleteSQLDirectory, + RenameSQLFile, + RenameSQLDirectory, +} from '../../../wailsjs/go/app/App'; +import { EventsOn } from '../../../wailsjs/runtime/runtime'; + +export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; + +export type SQLFileExecutionProgressState = { + fileSizeMB: string; + status: SQLFileExecutionStatus; + executed: number; + failed: number; + percent: number; + currentSQL: string; + resultMessage: string; +}; + +type SQLFileExecutionState = SQLFileExecutionProgressState & { + open: boolean; + jobId: string; + total: number; +}; + +type ActiveExecutionContext = { + connectionId?: string; + dbName?: string; +} | null | undefined; + +type RefreshExternalSQLRootNode = ( + showLoading?: boolean, + directoriesOverride?: ExternalSQLDirectory[], +) => Promise; + +type UseSidebarExternalSqlWorkflowOptions = { + connections: SavedConnection[]; + externalSQLDirectories: ExternalSQLDirectory[]; + activeTab: { + connectionId?: string; + dbName?: string; + } | null; + connectionIds: string[]; + selectedNodesRef: React.MutableRefObject; + addTab: (tab: any) => void; + saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void; + deleteExternalSQLDirectory: (directoryId: string) => void; + refreshGlobalExternalSQLRootNode: RefreshExternalSQLRootNode; + setExpandedKeys: React.Dispatch>; + setAutoExpandParent: React.Dispatch>; + getActiveContext: () => ActiveExecutionContext; +}; + +type ExternalSQLFileModalProps = { + open: boolean; + mode: ExternalSQLFileModalMode; + form: FormInstance; + onOk: () => void; + onCancel: () => void; +}; + +type SQLFileExecutionModalProps = { + title: React.ReactNode; + state: SQLFileExecutionState; + modalPanelStyle: React.CSSProperties; + onCancelExecution: () => void; + 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 ''; + return /\.sql$/i.test(name) ? name : `${name}.sql`; +}; + +const normalizeExternalSQLDirectoryName = (rawName: unknown): string => { + return String(rawName || '').trim(); +}; + +const getExternalSQLParentDirectoryPath = (node: any): string => { + const path = String(node?.dataRef?.path || '').trim(); + if (node?.type === 'external-sql-directory' || node?.type === 'external-sql-folder') { + return path; + } + if (node?.type === 'external-sql-file') { + const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return index > 0 ? path.slice(0, index) : ''; + } + return ''; +}; + +const normalizeSQLFileDialogData = (data: unknown): { content: string; filePath: string; fileName: string; isLargeFile: boolean; fileSizeMB?: string } => { + if (data && typeof data === 'object') { + const payload = data as Record; + const filePath = String(payload.filePath || '').trim(); + return { + content: String(payload.content ?? ''), + filePath, + fileName: String(payload.name || filePath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_file_exec.title')).trim(), + isLargeFile: payload.isLargeFile === true, + fileSizeMB: String(payload.fileSizeMB || '').trim() || undefined, + }; + } + return { + content: String(data || ''), + filePath: '', + fileName: t('sidebar.sql_file_exec.title'), + isLargeFile: false, + }; +}; + +const resolveSQLFileExecutionStatusLabel = (status: SQLFileExecutionStatus): string => { + switch (status) { + case 'done': + return `✅ ${t('sidebar.sql_file_exec.status.done')}`; + case 'cancelled': + return `⚠️ ${t('sidebar.sql_file_exec.status.cancelled')}`; + case 'error': + return `❌ ${t('sidebar.sql_file_exec.status.error')}`; + case 'running': + default: + return t('sidebar.sql_file_exec.status.running'); + } +}; + +export const buildSQLFileExecutionFooter = ({ + status, + onCancelExecution, + onClose, +}: { + status: SQLFileExecutionStatus; + onCancelExecution: () => void; + onClose: () => void; +}): React.ReactNode[] => { + if (status === 'running') { + return [ + , + ]; + } + + return [ + , + ]; +}; + +export const SQLFileExecutionProgressContent: React.FC = ({ + fileSizeMB, + status, + executed, + failed, + percent, + currentSQL, + resultMessage, +}) => ( + <> +
+ +
+
+
{t('sidebar.sql_file_exec.file_size')}{fileSizeMB} MB
+
{t('sidebar.sql_file_exec.status_label')}{resolveSQLFileExecutionStatusLabel(status)}
+
+ {t('sidebar.sql_file_exec.executed_label')} + {executed} + {t('sidebar.sql_file_exec.rows_separator')} + 0 ? '#ff4d4f' : undefined }}>{failed} + {t('sidebar.sql_file_exec.rows_suffix')} +
+
+ {currentSQL && status === 'running' && ( +
+ {currentSQL} +
+ )} + {resultMessage && status !== 'running' && ( +
+ {resultMessage} +
+ )} + +); + +export const ExternalSQLFileModal: React.FC = ({ + open, + mode, + form, + onOk, + onCancel, +}) => ( + +
+ { + const name = String(value || '').trim(); + if (!name) return; + if (/[\\/]/.test(name) || name === '.' || name === '..') { + throw new Error(isExternalSQLDirectoryModalMode(mode) ? t('sidebar.external_sql_modal.validation.directory_name_no_separator') : t('sidebar.external_sql_modal.validation.sql_file_name_no_separator')); + } + }, + }, + ]} + extra={isExternalSQLDirectoryModalMode(mode) ? t('sidebar.external_sql_modal.help.directory') : t('sidebar.external_sql_modal.help.sql_file')} + > + + +
+
+); + +export const SQLFileExecutionModal: React.FC = ({ + title, + state, + modalPanelStyle, + onCancelExecution, + onClose, +}) => ( + { + if (state.status !== 'running') { + onClose(); + } + }} + styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} + > + + +); + +export const useSidebarExternalSqlWorkflow = ({ + connections, + externalSQLDirectories, + activeTab, + connectionIds, + selectedNodesRef, + addTab, + saveExternalSQLDirectory, + deleteExternalSQLDirectory, + refreshGlobalExternalSQLRootNode, + setExpandedKeys, + setAutoExpandParent, + getActiveContext, +}: UseSidebarExternalSqlWorkflowOptions) => { + const [isExternalSQLFileModalOpen, setIsExternalSQLFileModalOpen] = useState(false); + 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 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 handleRunSQLFile = async (node: any) => { + const res = await OpenSQLFile(); + 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 || ''); + 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', + connectionId, + dbName: dbName, + query: data.content, + filePath: data.filePath || undefined, + }); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.read_file_failed', { error: res.message })); + } + }; + + const handleOpenSQLFileFromToolbar = async () => { + const ctx = getActiveContext(); + if (!ctx?.connectionId) { + message.warning(t('sidebar.message.select_connection_or_database_first')); + return; + } + const res = await OpenSQLFile(); + 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 || ''); + 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', + connectionId: ctx.connectionId, + dbName: ctx.dbName || undefined, + query: data.content, + filePath: data.filePath || undefined, + }); + } else if (res.message !== '已取消') { + message.error(t('sidebar.message.read_file_failed', { error: res.message })); + } + }; + + const resolveExternalSQLExecutionContext = (): { connectionId: string; dbName: string } => { + const activeStoreContext = getActiveContext(); + const selectedConnectionId = selectedNodesRef.current + .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) + .find(Boolean) || ''; + return { + connectionId: String( + activeStoreContext?.connectionId + || activeTab?.connectionId + || selectedConnectionId + || '', + ).trim(), + dbName: String( + activeStoreContext?.dbName + || activeTab?.dbName + || '', + ).trim(), + }; + }; + + const openExternalSQLFile = async (fileNode: any) => { + const fileContext = { + connectionId: String(fileNode?.dataRef?.connectionId || '').trim(), + dbName: String(fileNode?.dataRef?.dbName || '').trim(), + }; + const fallbackContext = resolveExternalSQLExecutionContext(); + const connectionId = fileContext.connectionId || fallbackContext.connectionId; + const dbName = fileContext.dbName || fallbackContext.dbName; + const filePath = String(fileNode?.dataRef?.path || '').trim(); + const fileName = String(fileNode?.dataRef?.name || fileNode?.title || t('sidebar.sql_file.default_name')).trim() || t('sidebar.sql_file.default_name'); + if (!filePath) { + message.error(t('sidebar.message.sql_file_path_incomplete')); + return; + } + + const res = await ReadSQLFile(filePath); + if (!res.success) { + if (res.message !== '已取消') { + message.error(t('sidebar.message.read_sql_file_failed', { error: res.message })); + } + return; + } + + const data = res.data; + if (data && typeof data === 'object' && data.isLargeFile) { + if (!connectionId) { + 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); + return; + } + + addTab({ + id: buildExternalSQLTabId(connectionId, dbName, filePath), + title: fileName, + type: 'query', + connectionId, + dbName: dbName || undefined, + query: String(data || ''), + filePath, + }); + }; + + const openCreateExternalSQLFileModal = (node: any) => { + const directoryPath = getExternalSQLParentDirectoryPath(node); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_file_parent_missing')); + return; + } + setExternalSQLFileModalMode('create'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: 'new-query.sql' }); + setIsExternalSQLFileModalOpen(true); + }; + + const openRenameExternalSQLFileModal = (node: any) => { + const currentName = String(node?.dataRef?.name || node?.title || '').trim(); + if (!currentName) { + message.error(t('sidebar.message.external_sql_file_rename_target_missing')); + return; + } + setExternalSQLFileModalMode('rename'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: currentName }); + setIsExternalSQLFileModalOpen(true); + }; + + const openCreateExternalSQLDirectoryModal = (node: any) => { + const directoryPath = getExternalSQLParentDirectoryPath(node); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_parent_missing')); + return; + } + setExternalSQLFileModalMode('create-directory'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: 'new-folder' }); + setIsExternalSQLFileModalOpen(true); + }; + + const openRenameExternalSQLDirectoryModal = (node: any) => { + const currentName = String(node?.dataRef?.name || node?.title || '').trim(); + if (!currentName) { + message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); + return; + } + setExternalSQLFileModalMode('rename-directory'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: currentName }); + setIsExternalSQLFileModalOpen(true); + }; + + const closeExternalSQLFileModal = () => { + setIsExternalSQLFileModalOpen(false); + setExternalSQLFileTarget(null); + externalSQLFileForm.resetFields(); + }; + + const handleExternalSQLFileModalOk = async () => { + try { + const values = await externalSQLFileForm.validateFields(); + const isDirectoryMode = isExternalSQLDirectoryModalMode(externalSQLFileModalMode); + const name = isDirectoryMode + ? normalizeExternalSQLDirectoryName(values.name) + : normalizeExternalSQLFileName(values.name); + if (!name) { + message.error(t(isDirectoryMode ? 'sidebar.message.sql_directory_name_required' : 'sidebar.message.sql_file_name_required')); + return; + } + + if (externalSQLFileModalMode === 'create') { + const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_file_parent_missing')); + return; + } + const res = await CreateSQLFile(directoryPath, name); + if (!res.success) { + message.error(t('sidebar.message.create_sql_file_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_file_created')); + } else if (externalSQLFileModalMode === 'rename') { + const filePath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); + if (!filePath) { + message.error(t('sidebar.message.external_sql_file_rename_target_missing')); + return; + } + const res = await RenameSQLFile(filePath, name); + if (!res.success) { + message.error(t('sidebar.message.rename_sql_file_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_file_renamed')); + } else if (externalSQLFileModalMode === 'create-directory') { + const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_parent_missing')); + return; + } + const res = await CreateSQLDirectory(directoryPath, name); + if (!res.success) { + message.error(t('sidebar.message.create_sql_directory_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_directory_created')); + } else { + const directoryPath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); + return; + } + const res = await RenameSQLDirectory(directoryPath, name); + if (!res.success) { + message.error(t('sidebar.message.rename_sql_directory_failed', { error: res.message })); + return; + } + + if (externalSQLFileTarget?.type === 'external-sql-directory') { + const payload = (res.data && typeof res.data === 'object') ? res.data as Record : {}; + const nextPath = String(payload.directoryPath || payload.path || '').trim(); + const nextName = String(payload.name || name).trim(); + const oldDirectoryId = String(externalSQLFileTarget?.dataRef?.id || '').trim(); + if (!nextPath || !oldDirectoryId) { + message.error(t('sidebar.message.external_sql_directory_rename_sync_failed')); + await refreshGlobalExternalSQLRootNode(false); + return; + } + const nextDirectory: ExternalSQLDirectory = { + id: buildExternalSQLDirectoryId('', '', nextPath), + name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), + path: nextPath, + createdAt: Number(externalSQLFileTarget?.dataRef?.createdAt) || Date.now(), + }; + deleteExternalSQLDirectory(oldDirectoryId); + saveExternalSQLDirectory(nextDirectory); + const nextDirectories = [ + ...externalSQLDirectories.filter((item) => item.id !== oldDirectoryId), + nextDirectory, + ]; + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + } else { + await refreshGlobalExternalSQLRootNode(false); + } + message.success(t('sidebar.message.sql_directory_renamed')); + } + + closeExternalSQLFileModal(); + } catch { + // Validate failed + } + }; + + const handleDeleteExternalSQLFile = (node: any) => { + const filePath = String(node?.dataRef?.path || '').trim(); + const fileName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_file.default_name')).trim(); + if (!filePath) { + message.error(t('sidebar.message.external_sql_file_delete_target_missing')); + return; + } + + Modal.confirm({ + title: t('sidebar.modal.confirm_delete_sql_file.title'), + content: t('sidebar.modal.confirm_delete_sql_file.content', { name: fileName }), + okText: t('sidebar.action.delete'), + cancelText: t('sidebar.action.cancel'), + okButtonProps: { danger: true }, + onOk: async () => { + const res = await DeleteSQLFile(filePath); + if (!res.success) { + message.error(t('sidebar.message.delete_sql_file_failed', { error: res.message })); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success(t('sidebar.message.sql_file_deleted')); + }, + }); + }; + + const handleDeleteExternalSQLDirectory = (node: any) => { + const directoryPath = String(node?.dataRef?.path || '').trim(); + const directoryName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_directory.default_name')).trim(); + if (!directoryPath) { + message.error(t('sidebar.message.external_sql_directory_delete_target_missing')); + return; + } + + Modal.confirm({ + title: t('sidebar.modal.confirm_delete_sql_directory.title'), + content: t('sidebar.modal.confirm_delete_sql_directory.content', { name: directoryName }), + okText: t('sidebar.action.delete'), + cancelText: t('sidebar.action.cancel'), + okButtonProps: { danger: true }, + onOk: async () => { + const res = await DeleteSQLDirectory(directoryPath); + if (!res.success) { + message.error(t('sidebar.message.delete_sql_directory_failed', { error: res.message })); + return; + } + + if (node?.type === 'external-sql-directory') { + const directoryId = String(node?.dataRef?.id || '').trim(); + if (directoryId) { + deleteExternalSQLDirectory(directoryId); + const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + } else { + await refreshGlobalExternalSQLRootNode(false); + } + } else { + await refreshGlobalExternalSQLRootNode(false); + } + message.success(t('sidebar.message.sql_directory_deleted')); + }, + }); + }; + + const handleAddExternalSQLDirectory = async (node: any) => { + void node; + const currentDirectory = externalSQLDirectories[0]?.path || ''; + const selection = await SelectSQLDirectory(currentDirectory); + if (!selection.success) { + if (selection.message !== '已取消') { + message.error(t('sidebar.message.select_sql_directory_failed', { error: selection.message })); + } + return; + } + + const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record : {}; + const path = String(payload.path || '').trim(); + const name = String(payload.name || '').trim(); + if (!path) { + message.error(t('sidebar.message.sql_directory_path_invalid')); + return; + } + + const directoryId = buildExternalSQLDirectoryId('', '', path); + const nextDirectory: ExternalSQLDirectory = { + id: directoryId, + name: name || path.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), + path, + createdAt: Date.now(), + }; + saveExternalSQLDirectory(nextDirectory); + + const nextDirectories = [ + ...externalSQLDirectories.filter((item) => item.path.replace(/\\/g, '/').toLowerCase() !== path.replace(/\\/g, '/').toLowerCase()), + nextDirectory, + ]; + setExpandedKeys((prev) => Array.from(new Set([...prev, 'external-sql-root']))); + setAutoExpandParent(false); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + message.success(t('sidebar.message.external_sql_directory_added')); + }; + + const handleRemoveExternalSQLDirectory = async (node: any) => { + const directoryId = String(node?.dataRef?.id || '').trim(); + if (!directoryId) { + message.error(t('sidebar.message.external_sql_directory_not_found')); + return; + } + deleteExternalSQLDirectory(directoryId); + const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + message.success(t('sidebar.message.external_sql_directory_removed')); + }; + + const handleRefreshExternalSQLDirectory = async (node: any) => { + void node; + await refreshGlobalExternalSQLRootNode(true); + 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, + openExternalSQLFile, + openCreateExternalSQLFileModal, + openRenameExternalSQLFileModal, + openCreateExternalSQLDirectoryModal, + openRenameExternalSQLDirectoryModal, + handleExternalSQLFileModalOk, + handleDeleteExternalSQLFile, + handleDeleteExternalSQLDirectory, + handleAddExternalSQLDirectory, + handleRemoveExternalSQLDirectory, + handleRefreshExternalSQLDirectory, + externalSQLFileModalProps: { + open: isExternalSQLFileModalOpen, + mode: externalSQLFileModalMode, + form: externalSQLFileForm, + onOk: handleExternalSQLFileModalOk, + onCancel: closeExternalSQLFileModal, + }, + sqlFileExecutionModalProps: { + state: sqlFileExecState, + onCancelExecution: cancelSQLFileExecution, + onClose: closeSQLFileExecutionModal, + }, + }; +};