diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1616b630..720eb01d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -35,6 +35,7 @@ import { } from './sidebar/useSidebarTreeLoaders'; export { formatSidebarDriverAgentUpdateWarning } from './sidebar/useSidebarTreeLoaders'; import { + ExternalSQLBindingModal, ExternalSQLFileModal, useSidebarExternalSqlWorkflow, } from './sidebar/SidebarExternalSqlWorkflow'; @@ -1462,6 +1463,7 @@ const Sidebar: React.FC<{ handleRunSQLFile, handleOpenSQLFileFromToolbar, openExternalSQLFile, + openExternalSQLBindingModal, openCreateExternalSQLFileModal, openRenameExternalSQLFileModal, openCreateExternalSQLDirectoryModal, @@ -1472,6 +1474,7 @@ const Sidebar: React.FC<{ handleRemoveExternalSQLDirectory, handleRefreshExternalSQLDirectory, externalSQLFileModalProps, + externalSQLBindingModalProps, } = useSidebarExternalSqlWorkflow({ connections, externalSQLDirectories, @@ -3087,6 +3090,7 @@ const Sidebar: React.FC<{ handleDeleteExternalSQLDirectory, handleRemoveExternalSQLDirectory, openExternalSQLFile, + openExternalSQLBindingModal, openRenameExternalSQLFileModal, handleDeleteExternalSQLFile, extractObjectName, @@ -4267,6 +4271,7 @@ const Sidebar: React.FC<{ + ({ id, @@ -53,4 +57,146 @@ describe('recent workbench shortcuts', () => { }), ]); }); + + it('uses the latest persisted file binding for recent SQL file shortcuts', () => { + const connections = [ + connection('mysql-1', 'mysql'), + connection('mysql-2', 'mysql'), + ]; + const directories: ExternalSQLDirectory[] = [{ + id: 'dir-1', + name: 'scripts', + path: 'D:/sql/scripts', + connectionId: 'mysql-1', + dbName: 'legacy', + fileBindings: [{ + filePath: 'D:/sql/scripts/report.sql', + connectionId: 'mysql-2', + dbName: 'reporting', + }], + createdAt: 1, + }]; + + expect(buildRecentSQLFileShortcuts(connections, directories, [{ + filePath: 'D:/sql/scripts/report.sql', + fileName: 'report.sql', + connectionId: 'mysql-1', + dbName: 'legacy', + openedAt: 1, + }])).toEqual([ + expect.objectContaining({ + connectionId: 'mysql-2', + dbName: 'reporting', + }), + ]); + }); + + it('shows a bound SQL file only once after its execution database changes', () => { + const connections = [ + connection('mysql-1', 'mysql'), + connection('mysql-2', 'mysql'), + ]; + const directories: ExternalSQLDirectory[] = [{ + id: 'dir-1', + name: 'scripts', + path: 'D:/sql/scripts', + connectionId: 'mysql-1', + dbName: 'crawler', + fileBindings: [{ + filePath: 'D:/sql/scripts/hancheng.sql', + connectionId: 'mysql-2', + dbName: '12315_dev', + }], + createdAt: 1, + }]; + + expect(buildRecentSQLFileShortcuts(connections, directories, [ + { + filePath: 'D:/sql/scripts/hancheng.sql', + fileName: 'hancheng.sql', + connectionId: 'mysql-2', + dbName: '12315_dev', + openedAt: 2, + }, + { + filePath: 'D:\\sql\\scripts\\hancheng.sql', + fileName: 'hancheng.sql', + connectionId: 'mysql-1', + dbName: 'crawler', + openedAt: 1, + }, + ])).toEqual([ + expect.objectContaining({ + filePath: 'D:/sql/scripts/hancheng.sql', + connectionId: 'mysql-2', + dbName: '12315_dev', + }), + ]); + }); + + it('preserves the recent context when the same directory path has multiple database bindings', () => { + const connections = [ + connection('mysql-1', 'mysql'), + connection('mysql-2', 'mysql'), + ]; + const directories: ExternalSQLDirectory[] = [ + { + id: 'dir-1', + name: 'scripts', + path: 'D:/sql/shared', + connectionId: 'mysql-1', + dbName: 'orders', + createdAt: 1, + }, + { + id: 'dir-2', + name: 'scripts', + path: 'D:/sql/shared', + connectionId: 'mysql-2', + dbName: 'reporting', + createdAt: 2, + }, + ]; + + expect(buildRecentSQLFileShortcuts(connections, directories, [{ + filePath: 'D:/sql/shared/report.sql', + fileName: 'report.sql', + connectionId: 'mysql-2', + dbName: 'reporting', + openedAt: 1, + }])).toEqual([ + expect.objectContaining({ + connectionId: 'mysql-2', + dbName: 'reporting', + }), + ]); + }); + + it('restores the original recent-file context after a file-specific binding is cleared', () => { + const connections = [ + connection('mysql-1', 'mysql'), + connection('mysql-2', 'mysql'), + ]; + const directories: ExternalSQLDirectory[] = [{ + id: 'dir-1', + name: 'scripts', + path: 'D:/sql/shared', + connectionId: 'mysql-1', + dbName: 'orders', + createdAt: 1, + }]; + + expect(buildRecentSQLFileShortcuts(connections, directories, [{ + filePath: 'D:/sql/shared/report.sql', + fileName: 'report.sql', + connectionId: 'mysql-2', + dbName: 'reporting', + openedAt: 1, + }])).toEqual([ + expect.objectContaining({ + connectionId: 'mysql-2', + dbName: 'reporting', + }), + ]); + }); }); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 450091a5..d6fa8efb 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -28,7 +28,11 @@ import { normalizeSQLFileReadContent, } from '../utils/sqlFileTabDirty'; import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; -import { buildExternalSQLTabId } from '../utils/externalSqlTree'; +import { + buildExternalSQLTabId, + normalizeExternalSQLPath, + resolveExternalSQLFileBinding, +} from '../utils/externalSqlTree'; import { buildSQLFileExecutionWorkbenchTab } from '../utils/sqlFileExecutionTab'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { CLOSE_ACTIVE_WORKSPACE_TAB_EVENT, resolveDockedActiveTabId } from '../utils/closeTabShortcut'; @@ -203,6 +207,37 @@ export const buildPinnedTableShortcuts = ( return result; }; +export const buildRecentSQLFileShortcuts = ( + connections: SavedConnection[], + directories: ExternalSQLDirectory[], + recentFiles: RecentSQLFile[], +): RecentSQLFile[] => { + const connectionIds = new Set(connections.map((connection) => connection.id)); + const seenFilePaths = new Set(); + return [...recentFiles] + .map((file) => { + const binding = resolveExternalSQLFileBinding(directories, file.filePath, { + connectionId: file.connectionId, + dbName: file.dbName, + }); + return binding + ? { ...file, connectionId: binding.connectionId, dbName: binding.dbName } + : file; + }) + .filter((file) => connectionIds.has(file.connectionId)) + .sort((left, right) => right.openedAt - left.openedAt) + .filter((file) => { + const normalizedPath = normalizeExternalSQLPath(file.filePath); + const filePathKey = /^[a-z]:\//iu.test(normalizedPath) || normalizedPath.startsWith('//') + ? normalizedPath.toLowerCase() + : normalizedPath; + if (!filePathKey || seenFilePaths.has(filePathKey)) return false; + seenFilePaths.add(filePathKey); + return true; + }) + .slice(0, RECENT_WORKBENCH_ITEM_LIMIT); +}; + const buildLinkedExternalSQLDirectoryShortcuts = ( connections: SavedConnection[], directories: ExternalSQLDirectory[], @@ -1368,11 +1403,8 @@ const TabManager: React.FC = React.memo(({ onF [connectionById, savedQueries], ); const recentSQLFileShortcuts = useMemo( - () => [...recentSQLFiles] - .filter((file) => connectionById.has(file.connectionId)) - .sort((left, right) => right.openedAt - left.openedAt) - .slice(0, RECENT_WORKBENCH_ITEM_LIMIT), - [connectionById, recentSQLFiles], + () => buildRecentSQLFileShortcuts(queryCapableConnections, externalSQLDirectories, recentSQLFiles), + [externalSQLDirectories, queryCapableConnections, recentSQLFiles], ); const pinnedTableShortcuts = useMemo( () => buildPinnedTableShortcuts(queryCapableConnections, pinnedSidebarTables), @@ -1450,9 +1482,13 @@ const TabManager: React.FC = React.memo(({ onF }, [addTab, connectionById]); const handleOpenRecentSQLFile = useCallback(async (file: RecentSQLFile) => { - const connectionId = String(file.connectionId || '').trim(); - const dbName = String(file.dbName || '').trim(); const filePath = String(file.filePath || '').trim(); + const fileBinding = resolveExternalSQLFileBinding(externalSQLDirectories, filePath, { + connectionId: file.connectionId, + dbName: file.dbName, + }); + const connectionId = String(fileBinding?.connectionId || file.connectionId || '').trim(); + const dbName = String(fileBinding?.dbName || file.dbName || '').trim(); if (!connectionId || !connectionById.has(connectionId)) { message.error(t('sidebar.message.connection_config_not_found')); return; @@ -1500,7 +1536,7 @@ const TabManager: React.FC = React.memo(({ onF } finally { setOpeningRecentSQLFileKey((current) => current === openKey ? null : current); } - }, [addTab, connectionById]); + }, [addTab, connectionById, externalSQLDirectories]); const EmptyWorkbench = (
diff --git a/frontend/src/components/ai/aiExternalSqlFileInsights.test.ts b/frontend/src/components/ai/aiExternalSqlFileInsights.test.ts index 553a39b9..369e187b 100644 --- a/frontend/src/components/ai/aiExternalSqlFileInsights.test.ts +++ b/frontend/src/components/ai/aiExternalSqlFileInsights.test.ts @@ -14,6 +14,16 @@ const connections: SavedConnection[] = [ user: 'root', }, }, + { + id: 'conn-2', + name: '报表库', + config: { + type: 'sqlserver', + host: '192.168.1.10', + port: 1433, + user: 'reporter', + }, + }, ]; describe('aiExternalSqlFileInsights', () => { @@ -25,6 +35,11 @@ describe('aiExternalSqlFileInsights', () => { path: 'D:/sql/reports', connectionId: 'conn-1', dbName: 'crm', + fileBindings: [{ + filePath: 'D:/sql/reports/daily.sql', + connectionId: 'conn-2', + dbName: 'reporting', + }], createdAt: 1, }, ]; @@ -56,9 +71,10 @@ describe('aiExternalSqlFileInsights', () => { expect(snapshot.hasMatchedDirectory).toBe(true); expect(snapshot.directory).toMatchObject({ name: '报表脚本', - connectionName: '本地开发库', - connectionType: 'mysql', - dbName: 'crm', + connectionName: '报表库', + connectionType: 'sqlserver', + dbName: 'reporting', + bindingSource: 'file', }); expect(snapshot.hasOpenTab).toBe(true); expect(snapshot.openTabCount).toBe(1); @@ -66,4 +82,32 @@ describe('aiExternalSqlFileInsights', () => { expect(snapshot.contentPreview).toBe('SELECT * FRO'); expect(snapshot.truncated).toBe(true); }); + + it('restores the original directory metadata after a file-specific binding is cleared', () => { + const snapshot = buildExternalSQLFileSnapshot({ + filePath: 'D:/sql/reports/daily.sql', + readResult: { + content: 'SELECT 1;', + filePath: 'D:/sql/reports/daily.sql', + name: 'daily.sql', + }, + externalSQLDirectories: [{ + id: 'dir-1', + name: '报表脚本', + path: 'D:/sql/reports', + connectionId: 'conn-1', + dbName: 'crm', + createdAt: 1, + }], + connections, + }); + + expect(snapshot.directory).toMatchObject({ + connectionId: 'conn-1', + connectionName: '本地开发库', + connectionType: 'mysql', + dbName: 'crm', + }); + expect(snapshot.directory).not.toHaveProperty('bindingSource'); + }); }); diff --git a/frontend/src/components/ai/aiExternalSqlFileInsights.ts b/frontend/src/components/ai/aiExternalSqlFileInsights.ts index 23702836..fe90da9e 100644 --- a/frontend/src/components/ai/aiExternalSqlFileInsights.ts +++ b/frontend/src/components/ai/aiExternalSqlFileInsights.ts @@ -1,4 +1,5 @@ import type { ExternalSQLDirectory, SavedConnection, TabData } from '../../types'; +import { resolveExternalSQLFileBinding } from '../../utils/externalSqlTree'; import { findBestMatchingExternalSQLDirectory, normalizeExternalSQLPath, @@ -73,7 +74,15 @@ export const buildExternalSQLFileSnapshot = (params: { const payload = normalizeFileReadPayload(readResult); const resolvedFilePath = normalizeExternalSQLPath(payload.filePath || requestedFilePath); const matchedDirectory = findBestMatchingExternalSQLDirectory(resolvedFilePath, externalSQLDirectories); - const matchedConnection = connections.find((item) => item.id === matchedDirectory?.connectionId); + const fileBinding = resolveExternalSQLFileBinding( + externalSQLDirectories, + resolvedFilePath, + matchedDirectory + ? { connectionId: matchedDirectory.connectionId, dbName: matchedDirectory.dbName } + : undefined, + ); + const effectiveConnectionId = fileBinding?.connectionId || matchedDirectory?.connectionId || ''; + const matchedConnection = connections.find((item) => item.id === effectiveConnectionId); const matchingTabs = tabs.filter( (tab) => normalizeExternalSQLPath(tab.filePath || '').toLowerCase() === resolvedFilePath.toLowerCase(), ); @@ -91,10 +100,11 @@ export const buildExternalSQLFileSnapshot = (params: { id: matchedDirectory.id, name: matchedDirectory.name, path: matchedDirectory.path, - connectionId: matchedDirectory.connectionId || '', + connectionId: effectiveConnectionId, connectionName: matchedConnection?.name || '', connectionType: matchedConnection?.config?.type || '', - dbName: matchedDirectory.dbName || '', + dbName: fileBinding?.dbName || matchedDirectory.dbName || '', + ...(fileBinding ? { bindingSource: 'file' } : {}), } : null, hasOpenTab: matchingTabs.length > 0, openTabCount: matchingTabs.length, diff --git a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx index afa793d1..351368bc 100644 --- a/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx +++ b/frontend/src/components/sidebar/SidebarExternalSqlWorkflow.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useState } from 'react'; -import { Button, Form, Input, Progress, message } from 'antd'; +import React, { useCallback, useRef, useState } from 'react'; +import { Button, Form, Input, Progress, Select, message } from 'antd'; import type { FormInstance } from 'antd/es/form'; import Modal from '../common/ResizableDraggableModal'; import type { SavedConnection, ExternalSQLDirectory } from '../../types'; @@ -7,9 +7,17 @@ import { noAutoCapInputProps } from '../../utils/inputAutoCap'; import { buildExternalSQLDirectoryId, buildExternalSQLTabId, + moveExternalSQLFileBindings, normalizeExternalSQLPath, + removeExternalSQLFileBindings, + resolveExternalSQLFileBinding, + setExternalSQLFileBinding, } from '../../utils/externalSqlTree'; import { buildSQLFileExecutionWorkbenchTab } from '../../utils/sqlFileExecutionTab'; +import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; +import { filterVisibleDatabaseNames } from '../../utils/databaseVisibility'; +import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities'; +import { resolveConnectionHostSummary } from '../../utils/tabDisplay'; import { t } from '../../i18n'; import { resolveSidebarNodeConnectionId } from '../sidebarV2Utils'; import { @@ -20,6 +28,7 @@ import { OpenSQLFile, SelectSQLDirectory, ReadSQLFile, + DBGetDatabases, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, @@ -94,6 +103,22 @@ type SQLFileExecutionModalProps = { onClose: () => void; }; +type ExternalSQLBindingModalProps = { + open: boolean; + form: FormInstance; + connections: SavedConnection[]; + filePath: string; + databaseOptions: string[]; + loadingDatabases: boolean; + databaseLoadError: string; + hasExplicitBinding: boolean; + saving: boolean; + onConnectionChange: (connectionId: string) => void; + onClearBinding: () => void; + onOk: () => void; + onCancel: () => void; +}; + const normalizeExternalSQLFileName = (rawName: unknown): string => { const name = String(rawName || '').trim(); if (!name) return ''; @@ -265,6 +290,90 @@ export const ExternalSQLFileModal: React.FC = ({ ); +export const ExternalSQLBindingModal: React.FC = ({ + open, + form, + connections, + filePath, + databaseOptions, + loadingDatabases, + databaseLoadError, + hasExplicitBinding, + saving, + onConnectionChange, + onClearBinding, + onOk, + onCancel, +}) => { + const connectionOptions = connections + .filter((connection) => getDataSourceCapabilities(connection.config).supportsQueryEditor) + .map((connection) => { + const host = resolveConnectionHostSummary(connection.config); + return { + value: connection.id, + label: host ? `${connection.name || connection.id} (${host})` : connection.name || connection.id, + }; + }); + + return ( + +
+ {filePath} +
+
+ + ({ value: database, label: database }))} + placeholder={loadingDatabases + ? t('data_export.workbench.placeholder.loading_databases') + : t('data_export.workbench.placeholder.select_database')} + /> + +
+ {hasExplicitBinding && ( + + )} +
+ ); +}; + export const SQLFileExecutionModal: React.FC = ({ title, state, @@ -324,6 +433,58 @@ export const useSidebarExternalSqlWorkflow = ({ const [externalSQLFileForm] = Form.useForm(); const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState('create'); const [externalSQLFileTarget, setExternalSQLFileTarget] = useState(null); + const [isExternalSQLBindingModalOpen, setIsExternalSQLBindingModalOpen] = useState(false); + const [externalSQLBindingForm] = Form.useForm(); + const [externalSQLBindingTarget, setExternalSQLBindingTarget] = useState(null); + const [externalSQLBindingDatabases, setExternalSQLBindingDatabases] = useState([]); + const [externalSQLBindingDatabaseError, setExternalSQLBindingDatabaseError] = useState(''); + const [loadingExternalSQLBindingDatabases, setLoadingExternalSQLBindingDatabases] = useState(false); + const [savingExternalSQLBinding, setSavingExternalSQLBinding] = useState(false); + const externalSQLBindingDatabaseRequestRef = useRef(0); + + const loadExternalSQLBindingDatabases = useCallback(async ( + connectionId: string, + preferredDbName = '', + ) => { + const requestId = ++externalSQLBindingDatabaseRequestRef.current; + const connection = connections.find((item) => item.id === String(connectionId || '').trim()); + setExternalSQLBindingDatabaseError(''); + if (!connection) { + setExternalSQLBindingDatabases([]); + setLoadingExternalSQLBindingDatabases(false); + return; + } + + setLoadingExternalSQLBindingDatabases(true); + const fallbackNames = [preferredDbName, String(connection.config.database || '').trim()].filter(Boolean); + try { + const result = await DBGetDatabases(buildRpcConnectionConfig(connection.config) as any); + if (requestId !== externalSQLBindingDatabaseRequestRef.current) return; + if (!result.success) { + setExternalSQLBindingDatabases(Array.from(new Set(fallbackNames))); + setExternalSQLBindingDatabaseError(result.message || t('data_export.message.load_databases_failed')); + return; + } + const names = (Array.isArray(result.data) ? result.data : []) + .map((row: any) => String(row?.Database || row?.database || Object.values(row || {})[0] || '').trim()) + .filter(Boolean); + const visibleNames = filterVisibleDatabaseNames(connection, names); + setExternalSQLBindingDatabases( + Array.from(new Set([...fallbackNames, ...visibleNames])) + .sort((left, right) => left.localeCompare(right)), + ); + } catch (error) { + if (requestId !== externalSQLBindingDatabaseRequestRef.current) return; + setExternalSQLBindingDatabases(Array.from(new Set(fallbackNames))); + setExternalSQLBindingDatabaseError( + error instanceof Error ? error.message : t('data_export.message.load_databases_failed'), + ); + } finally { + if (requestId === externalSQLBindingDatabaseRequestRef.current) { + setLoadingExternalSQLBindingDatabases(false); + } + } + }, [connections]); const selectSQLFileForExecution = useCallback(async () => { const backendApp = typeof window !== 'undefined' ? (window as any).go?.app?.App : undefined; @@ -409,9 +570,19 @@ export const useSidebarExternalSqlWorkflow = ({ message.error(t('sidebar.message.sql_file_path_incomplete')); return; } + const fileBinding = resolveExternalSQLFileBinding( + externalSQLDirectories, + data.filePath, + { + connectionId: String(ctx?.connectionId || '').trim(), + dbName: String(ctx?.dbName || '').trim(), + }, + ); + const connectionId = fileBinding?.connectionId || ctx.connectionId; + const dbName = fileBinding?.dbName || String(ctx.dbName || '').trim(); openSQLFileExecutionWorkbench({ - connectionId: ctx.connectionId, - dbName: ctx.dbName || '', + connectionId, + dbName, filePath: data.filePath, fileName: data.fileName, fileSizeMB: data.fileSizeMB, @@ -441,6 +612,125 @@ export const useSidebarExternalSqlWorkflow = ({ }; }; + const closeExternalSQLBindingModal = () => { + externalSQLBindingDatabaseRequestRef.current += 1; + setIsExternalSQLBindingModalOpen(false); + setExternalSQLBindingTarget(null); + setExternalSQLBindingDatabases([]); + setExternalSQLBindingDatabaseError(''); + setLoadingExternalSQLBindingDatabases(false); + externalSQLBindingForm.resetFields(); + }; + + const openExternalSQLBindingModal = (fileNode: any) => { + const filePath = String(fileNode?.dataRef?.path || '').trim(); + const directoryId = String(fileNode?.dataRef?.directoryId || '').trim(); + if (!filePath || !directoryId) { + message.error(t('sidebar.message.sql_file_path_incomplete')); + return; + } + const fallbackContext = resolveExternalSQLExecutionContext(); + const fileConnectionId = String(fileNode?.dataRef?.connectionId || '').trim(); + const fallbackConnectionId = String(fallbackContext.connectionId || '').trim(); + const connectionId = [fileConnectionId, fallbackConnectionId] + .find((candidate) => connections.some((connection) => connection.id === candidate)) || ''; + const dbName = connectionId === fileConnectionId + ? String(fileNode?.dataRef?.dbName || '').trim() + : String(fallbackContext.dbName || '').trim(); + setExternalSQLBindingTarget(fileNode); + externalSQLBindingForm.setFieldsValue({ + connectionId: connectionId || undefined, + dbName: dbName || undefined, + }); + setIsExternalSQLBindingModalOpen(true); + if (connectionId) { + void loadExternalSQLBindingDatabases(connectionId, dbName); + } else { + setExternalSQLBindingDatabases([]); + setExternalSQLBindingDatabaseError(''); + } + }; + + const handleExternalSQLBindingConnectionChange = (connectionId: string) => { + externalSQLBindingForm.setFieldsValue({ dbName: undefined }); + void loadExternalSQLBindingDatabases(connectionId); + }; + + const saveExternalSQLBindingTarget = async ( + target: { connectionId: string; dbName: string } | null, + ) => { + const filePath = String(externalSQLBindingTarget?.dataRef?.path || '').trim(); + const directoryId = String(externalSQLBindingTarget?.dataRef?.directoryId || '').trim(); + const directory = externalSQLDirectories.find((item) => item.id === directoryId); + if (!filePath || !directory) { + message.error(t('sidebar.message.external_sql_directory_not_found')); + return false; + } + if (target) { + const connection = connections.find((item) => item.id === target.connectionId); + if (!connection || !getDataSourceCapabilities(connection.config).supportsQueryEditor) { + message.error(t('sidebar.message.connection_config_not_found')); + return false; + } + } + const nextDirectory = setExternalSQLFileBinding(directory, filePath, target); + saveExternalSQLDirectory(nextDirectory); + const nextDirectories = externalSQLDirectories.map((item) => ( + item.id === directoryId ? nextDirectory : item + )); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); + return true; + }; + + const handleExternalSQLBindingOk = async () => { + try { + const values = await externalSQLBindingForm.validateFields(); + setSavingExternalSQLBinding(true); + const saved = await saveExternalSQLBindingTarget({ + connectionId: String(values.connectionId || '').trim(), + dbName: String(values.dbName || '').trim(), + }); + if (!saved) return; + message.success(t('sidebar.message.external_sql_file_binding_saved')); + closeExternalSQLBindingModal(); + } catch (error) { + if (!(error && typeof error === 'object' && 'errorFields' in error)) { + message.error(error instanceof Error ? error.message : t('common.unknown')); + } + } finally { + setSavingExternalSQLBinding(false); + } + }; + + const handleClearExternalSQLBinding = async () => { + try { + setSavingExternalSQLBinding(true); + const saved = await saveExternalSQLBindingTarget(null); + if (!saved) return; + message.success(t('sidebar.message.external_sql_file_binding_cleared')); + closeExternalSQLBindingModal(); + } catch (error) { + message.error(error instanceof Error ? error.message : t('common.unknown')); + } finally { + setSavingExternalSQLBinding(false); + } + }; + + const transformExternalSQLDirectoryBindings = ( + transform: (directory: ExternalSQLDirectory) => ExternalSQLDirectory, + ): ExternalSQLDirectory[] | undefined => { + let changed = false; + const nextDirectories = externalSQLDirectories.map((directory) => { + const nextDirectory = transform(directory); + if (nextDirectory !== directory) { + changed = true; + saveExternalSQLDirectory(nextDirectory); + } + return nextDirectory; + }); + return changed ? nextDirectories : undefined; + }; + const openExternalSQLFile = async (fileNode: any) => { const fileContext = { connectionId: String(fileNode?.dataRef?.connectionId || '').trim(), @@ -455,7 +745,6 @@ export const useSidebarExternalSqlWorkflow = ({ message.error(t('sidebar.message.sql_file_path_incomplete')); return; } - const res = await ReadSQLFile(filePath); if (!res.success) { if (res.message !== '已取消') { @@ -583,10 +872,14 @@ export const useSidebarExternalSqlWorkflow = ({ } const payload = (res.data && typeof res.data === 'object') ? res.data as Record : {}; const nextFilePath = String(payload.filePath || '').trim(); + let nextDirectories: ExternalSQLDirectory[] | undefined; if (nextFilePath) { updateRecentSQLFilePath(filePath, nextFilePath); + nextDirectories = transformExternalSQLDirectoryBindings( + (directory) => moveExternalSQLFileBindings(directory, filePath, nextFilePath), + ); } - await refreshGlobalExternalSQLRootNode(false); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); message.success(t('sidebar.message.sql_file_renamed')); } else if (externalSQLFileModalMode === 'create-directory') { const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); @@ -636,8 +929,9 @@ export const useSidebarExternalSqlWorkflow = ({ matchingDirectories.forEach((directory) => { const connectionId = String(directory.connectionId || '').trim(); const dbName = String(directory.dbName || '').trim(); + const movedDirectory = moveExternalSQLFileBindings(directory, directoryPath, nextPath); const nextDirectory: ExternalSQLDirectory = { - ...directory, + ...movedDirectory, id: buildExternalSQLDirectoryId(connectionId, dbName, nextPath), name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), path: nextPath, @@ -656,7 +950,12 @@ export const useSidebarExternalSqlWorkflow = ({ nextDirectoriesById.forEach((directory) => saveExternalSQLDirectory(directory)); await refreshGlobalExternalSQLRootNode(false, nextDirectories); } else { - await refreshGlobalExternalSQLRootNode(false); + const nextDirectories = nextPath + ? transformExternalSQLDirectoryBindings( + (directory) => moveExternalSQLFileBindings(directory, directoryPath, nextPath), + ) + : undefined; + await refreshGlobalExternalSQLRootNode(false, nextDirectories); } message.success(t('sidebar.message.sql_directory_renamed')); } @@ -688,7 +987,10 @@ export const useSidebarExternalSqlWorkflow = ({ return; } removeRecentSQLFilesByPath(filePath); - await refreshGlobalExternalSQLRootNode(false); + const nextDirectories = transformExternalSQLDirectoryBindings( + (directory) => removeExternalSQLFileBindings(directory, filePath), + ); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); message.success(t('sidebar.message.sql_file_deleted')); }, }); @@ -731,7 +1033,10 @@ export const useSidebarExternalSqlWorkflow = ({ await refreshGlobalExternalSQLRootNode(false); } } else { - await refreshGlobalExternalSQLRootNode(false); + const nextDirectories = transformExternalSQLDirectoryBindings( + (directory) => removeExternalSQLFileBindings(directory, directoryPath), + ); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); } message.success(t('sidebar.message.sql_directory_deleted')); }, @@ -803,6 +1108,7 @@ export const useSidebarExternalSqlWorkflow = ({ handleRunSQLFile, handleOpenSQLFileFromToolbar, openExternalSQLFile, + openExternalSQLBindingModal, openCreateExternalSQLFileModal, openRenameExternalSQLFileModal, openCreateExternalSQLDirectoryModal, @@ -820,5 +1126,22 @@ export const useSidebarExternalSqlWorkflow = ({ onOk: handleExternalSQLFileModalOk, onCancel: closeExternalSQLFileModal, }, + externalSQLBindingModalProps: { + open: isExternalSQLBindingModalOpen, + form: externalSQLBindingForm, + connections, + filePath: String(externalSQLBindingTarget?.dataRef?.path || '').trim(), + databaseOptions: externalSQLBindingDatabases, + loadingDatabases: loadingExternalSQLBindingDatabases, + databaseLoadError: externalSQLBindingDatabaseError, + hasExplicitBinding: externalSQLBindingTarget?.dataRef?.hasExplicitBinding === true, + saving: savingExternalSQLBinding, + onConnectionChange: handleExternalSQLBindingConnectionChange, + onClearBinding: () => { void handleClearExternalSQLBinding(); }, + onOk: () => { void handleExternalSQLBindingOk(); }, + onCancel: () => { + if (!savingExternalSQLBinding) closeExternalSQLBindingModal(); + }, + }, }; }; diff --git a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.external-sql.test.tsx b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.external-sql.test.tsx new file mode 100644 index 00000000..75baa77a --- /dev/null +++ b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.external-sql.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildSidebarLegacyNodeMenuItems } from './sidebarLegacyNodeMenu'; + +describe('external SQL file context menu', () => { + it('opens the persisted database binding workflow from the shared legacy/v2 menu', () => { + const openExternalSQLFile = vi.fn(); + const openExternalSQLBindingModal = vi.fn(); + const items = buildSidebarLegacyNodeMenuItems({ + type: 'external-sql-file', + title: 'report.sql', + dataRef: { + path: 'D:/sql/report.sql', + directoryId: 'dir-1', + }, + }, { + openExternalSQLFile, + openExternalSQLBindingModal, + openRenameExternalSQLFileModal: vi.fn(), + openCreateExternalSQLFileModal: vi.fn(), + openCreateExternalSQLDirectoryModal: vi.fn(), + handleDeleteExternalSQLFile: vi.fn(), + }) as any[]; + + expect(items.map((item) => item?.key)).toContain('bind-external-sql-file-database'); + items.find((item) => item?.key === 'bind-external-sql-file-database')?.onClick?.(); + expect(openExternalSQLBindingModal).toHaveBeenCalledWith(expect.objectContaining({ + type: 'external-sql-file', + })); + expect(openExternalSQLFile).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx index 3978e665..29198455 100644 --- a/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx +++ b/frontend/src/components/sidebar/sidebarLegacyNodeMenu.tsx @@ -382,6 +382,7 @@ export const buildSidebarLegacyNodeMenuItems = ( handleDeleteExternalSQLDirectory, handleRemoveExternalSQLDirectory, openExternalSQLFile, + openExternalSQLBindingModal, openRenameExternalSQLFileModal, handleDeleteExternalSQLFile, extractObjectName, @@ -1855,6 +1856,14 @@ export const buildSidebarLegacyNodeMenuItems = ( void openExternalSQLFile(node); } }, + { + key: 'bind-external-sql-file-database', + label: t('sidebar.menu.bind_sql_file_database'), + icon: , + onClick: () => { + openExternalSQLBindingModal(node); + } + }, { key: 'rename-external-sql-file', label: t('sidebar.menu.rename_sql_file'), diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index e10b52dc..d2b64c0c 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -1958,6 +1958,13 @@ describe('store appearance persistence', () => { id: 'ext-1', name: 'scripts', path: 'D:/sql/scripts', + fileBindings: [ + { + filePath: 'D:\\sql\\scripts\\report.sql', + connectionId: 'conn-2', + dbName: 'reporting', + }, + ], createdAt: 1, }); @@ -1967,6 +1974,13 @@ describe('store appearance persistence', () => { id: 'ext-1', name: 'scripts', path: 'D:/sql/scripts', + fileBindings: [ + { + filePath: 'D:/sql/scripts/report.sql', + connectionId: 'conn-2', + dbName: 'reporting', + }, + ], createdAt: 1, }, ]); @@ -1996,6 +2010,13 @@ describe('store appearance persistence', () => { id: 'ext-1', name: 'scripts', path: 'D:/sql/scripts', + fileBindings: [ + { + filePath: 'D:/sql/scripts/report.sql', + connectionId: 'conn-2', + dbName: 'reporting', + }, + ], createdAt: 1, }, { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 05f48a30..4915c4d4 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -30,7 +30,10 @@ import { type ShortcutPlatformBinding, type ShortcutPlatform, } from "./utils/shortcuts"; -import { buildExternalSQLDirectoryId } from "./utils/externalSqlTree"; +import { + buildExternalSQLDirectoryId, + normalizeExternalSQLPath, +} from "./utils/externalSqlTree"; import { DEFAULT_SQL_SNIPPETS, BUILTIN_SNIPPET_MAP, @@ -2171,6 +2174,23 @@ const resolveExternalSQLDirectoryName = (name: unknown, path: string): string => return pathSegment || translate("sidebar.sql_directory.default_name"); }; +const sanitizeExternalSQLFileBindings = ( + value: unknown, +): NonNullable => { + if (!Array.isArray(value)) return []; + const bindings = new Map[number]>(); + value.forEach((entry) => { + if (!entry || typeof entry !== "object") return; + const raw = entry as Record; + const filePath = normalizeExternalSQLPath(toTrimmedString(raw.filePath)); + const connectionId = toTrimmedString(raw.connectionId); + const dbName = toTrimmedString(raw.dbName); + if (!filePath || !connectionId || !dbName) return; + bindings.set(filePath, { filePath, connectionId, dbName }); + }); + return [...bindings.values()]; +}; + const sanitizeExternalSQLDirectories = ( value: unknown, ): ExternalSQLDirectory[] => { @@ -2184,6 +2204,7 @@ const sanitizeExternalSQLDirectories = ( if (!path) return; const connectionId = toTrimmedString(raw.connectionId); const dbName = toTrimmedString(raw.dbName); + const fileBindings = sanitizeExternalSQLFileBindings(raw.fileBindings); const id = toTrimmedString( raw.id, @@ -2197,6 +2218,7 @@ const sanitizeExternalSQLDirectories = ( path, ...(connectionId ? { connectionId } : {}), ...(dbName ? { dbName } : {}), + ...(fileBindings.length > 0 ? { fileBindings } : {}), createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(), @@ -5005,6 +5027,7 @@ export const useStore = create()( } const connectionId = toTrimmedString(directory.connectionId); const dbName = toTrimmedString(directory.dbName); + const fileBindings = sanitizeExternalSQLFileBindings(directory.fileBindings); const nextDirectory: ExternalSQLDirectory = { id: toTrimmedString( @@ -5015,6 +5038,7 @@ export const useStore = create()( path, ...(connectionId ? { connectionId } : {}), ...(dbName ? { dbName } : {}), + ...(fileBindings.length > 0 ? { fileBindings } : {}), createdAt: Number.isFinite(Number(directory.createdAt)) ? Number(directory.createdAt) : Date.now(), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9187e153..a0e18588 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -623,9 +623,16 @@ export interface ExternalSQLDirectory { path: string; connectionId?: string; dbName?: string; + fileBindings?: ExternalSQLFileBinding[]; createdAt: number; } +export interface ExternalSQLFileBinding { + filePath: string; + connectionId: string; + dbName: string; +} + export interface ExternalSQLTreeEntry { name: string; path: string; diff --git a/frontend/src/utils/externalSqlTree.test.ts b/frontend/src/utils/externalSqlTree.test.ts index 12fec87d..a1a3c29c 100644 --- a/frontend/src/utils/externalSqlTree.test.ts +++ b/frontend/src/utils/externalSqlTree.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest'; import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types'; -import { buildExternalSQLRootNode, buildExternalSQLTabId } from './externalSqlTree'; +import { + buildExternalSQLRootNode, + buildExternalSQLTabId, + moveExternalSQLFileBindings, + removeExternalSQLFileBindings, + resolveExternalSQLFileBinding, + setExternalSQLFileBinding, +} from './externalSqlTree'; describe('externalSqlTree helpers', () => { it('builds external SQL root node with nested directory and file entries', () => { @@ -145,6 +152,53 @@ describe('externalSqlTree helpers', () => { }); }); + it('uses a file binding ahead of its directory binding without affecting sibling files', () => { + const node = buildExternalSQLRootNode({ + directories: [ + { + id: 'dir-bound', + name: 'bound scripts', + path: 'D:/sql/bound', + connectionId: 'connection-1', + dbName: 'orders', + fileBindings: [ + { + filePath: 'D:/sql/bound/report.sql', + connectionId: 'connection-2', + dbName: 'reporting', + }, + ], + createdAt: 1, + }, + ], + directoryTrees: { + 'dir-bound': [ + { + name: 'report.sql', + path: 'D:/sql/bound/report.sql', + isDir: false, + }, + { + name: 'orders.sql', + path: 'D:/sql/bound/orders.sql', + isDir: false, + }, + ], + }, + }); + + expect(node.children?.[0]?.children?.[0]?.dataRef).toMatchObject({ + connectionId: 'connection-2', + dbName: 'reporting', + hasExplicitBinding: true, + }); + expect(node.children?.[0]?.children?.[1]?.dataRef).toMatchObject({ + connectionId: 'connection-1', + dbName: 'orders', + }); + expect(node.children?.[0]?.children?.[1]?.dataRef).not.toHaveProperty('hasExplicitBinding'); + }); + it('keeps same-path directories separate when they target different databases', () => { const node = buildExternalSQLRootNode({ directories: [ @@ -183,6 +237,120 @@ describe('externalSqlTree helpers', () => { }); }); + it('updates file bindings when a file or containing folder moves and removes deleted subtrees', () => { + const directory: ExternalSQLDirectory = { + id: 'dir-1', + name: 'scripts', + path: 'D:/sql/scripts', + createdAt: 1, + }; + const bound = setExternalSQLFileBinding(directory, 'D:\\sql\\scripts\\daily.sql', { + connectionId: 'connection-1', + dbName: 'orders', + }); + const movedFile = moveExternalSQLFileBindings( + bound, + 'D:/sql/scripts/daily.sql', + 'D:/sql/scripts/archive/daily.sql', + ); + const movedFolder = moveExternalSQLFileBindings( + movedFile, + 'D:/sql/scripts/archive', + 'D:/sql/scripts/history', + ); + + expect(movedFolder.fileBindings).toEqual([{ + filePath: 'D:/sql/scripts/history/daily.sql', + connectionId: 'connection-1', + dbName: 'orders', + }]); + expect(removeExternalSQLFileBindings(movedFolder, 'D:/sql/scripts/history').fileBindings).toBeUndefined(); + }); + + it('resolves only persisted file bindings without replacing directory defaults', () => { + const directories: ExternalSQLDirectory[] = [ + { + id: 'dir-1', + name: 'scripts', + path: 'D:/sql/scripts', + connectionId: 'connection-1', + dbName: 'orders', + fileBindings: [{ + filePath: 'D:/sql/scripts/report.sql', + connectionId: 'connection-2', + dbName: 'reporting', + }], + createdAt: 1, + }, + ]; + + expect(resolveExternalSQLFileBinding(directories, 'D:\\sql\\scripts\\report.sql')).toEqual({ + connectionId: 'connection-2', + dbName: 'reporting', + hasExplicitBinding: true, + }); + expect(resolveExternalSQLFileBinding( + directories, + 'D:/sql/scripts/orders.sql', + )).toBeUndefined(); + expect(resolveExternalSQLFileBinding([{ + id: 'root-dir', + name: 'root', + path: '/', + fileBindings: [{ + filePath: '/var/sql/report.sql', + connectionId: 'connection-root', + dbName: 'main', + }], + createdAt: 1, + }], '/var/sql/report.sql')).toEqual({ + connectionId: 'connection-root', + dbName: 'main', + hasExplicitBinding: true, + }); + }); + + it('scopes explicit binding lookup to the preferred same-path directory', () => { + const directories: ExternalSQLDirectory[] = [ + { + id: 'dir-orders', + name: 'scripts', + path: 'D:/sql/shared', + connectionId: 'connection-1', + dbName: 'orders', + createdAt: 1, + }, + { + id: 'dir-reporting', + name: 'scripts', + path: 'D:/sql/shared', + connectionId: 'connection-2', + dbName: 'reporting', + fileBindings: [{ + filePath: 'D:/sql/shared/report.sql', + connectionId: 'connection-3', + dbName: 'warehouse', + }], + createdAt: 2, + }, + ]; + + expect(resolveExternalSQLFileBinding( + directories, + 'D:/sql/shared/report.sql', + { connectionId: 'connection-1', dbName: 'orders' }, + )).toBeUndefined(); + expect(resolveExternalSQLFileBinding( + directories, + 'D:/sql/shared/report.sql', + { connectionId: 'connection-2', dbName: 'reporting' }, + )).toEqual({ + connectionId: 'connection-3', + dbName: 'warehouse', + hasExplicitBinding: true, + }); + }); + it('filters non-sql file entries even when the backend returns them', () => { const directories: ExternalSQLDirectory[] = [ { diff --git a/frontend/src/utils/externalSqlTree.ts b/frontend/src/utils/externalSqlTree.ts index 76b737c3..7a4e42b9 100644 --- a/frontend/src/utils/externalSqlTree.ts +++ b/frontend/src/utils/externalSqlTree.ts @@ -1,4 +1,8 @@ -import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types'; +import type { + ExternalSQLDirectory, + ExternalSQLFileBinding, + ExternalSQLTreeEntry, +} from '../types'; export type ExternalSQLNodeType = | 'external-sql-root' @@ -32,6 +36,141 @@ export type ExternalSQLTreeLabels = { export const normalizeExternalSQLPath = (value: string): string => String(value || '').trim().replace(/\\/g, '/'); +export const findExternalSQLFileBinding = ( + bindings: ExternalSQLFileBinding[] | undefined, + filePath: string, +): ExternalSQLFileBinding | undefined => { + const normalizedFilePath = normalizeExternalSQLPath(filePath); + if (!normalizedFilePath) return undefined; + return bindings?.find( + (binding) => normalizeExternalSQLPath(binding.filePath) === normalizedFilePath, + ); +}; + +export const resolveExternalSQLFileBinding = ( + directories: ExternalSQLDirectory[], + filePath: string, + preferredContext?: { connectionId?: string; dbName?: string }, +): { connectionId: string; dbName: string; hasExplicitBinding: boolean } | undefined => { + const normalizedFilePath = normalizeExternalSQLPath(filePath); + if (!normalizedFilePath) return undefined; + const matchingDirectories = [...directories] + .filter((directory) => { + const rawDirectoryPath = normalizeExternalSQLPath(directory.path); + const directoryPath = rawDirectoryPath === '/' ? '/' : rawDirectoryPath.replace(/\/+$/u, ''); + return Boolean(directoryPath) && ( + directoryPath === '/' + ? normalizedFilePath.startsWith('/') + : normalizedFilePath === directoryPath || normalizedFilePath.startsWith(`${directoryPath}/`) + ); + }) + .sort((left, right) => ( + normalizeExternalSQLPath(right.path).length - normalizeExternalSQLPath(left.path).length + )); + const preferredConnectionId = String(preferredContext?.connectionId || '').trim(); + const preferredDbName = String(preferredContext?.dbName || '').trim(); + const preferredDirectory = preferredConnectionId + ? matchingDirectories.find((directory) => ( + String(directory.connectionId || '').trim() === preferredConnectionId + && (!preferredDbName || String(directory.dbName || '').trim() === preferredDbName) + )) + : undefined; + if (preferredDirectory) { + const binding = findExternalSQLFileBinding(preferredDirectory.fileBindings, normalizedFilePath); + return binding + ? { + connectionId: String(binding.connectionId || '').trim(), + dbName: String(binding.dbName || '').trim(), + hasExplicitBinding: true, + } + : undefined; + } + for (const directory of matchingDirectories) { + const binding = findExternalSQLFileBinding(directory.fileBindings, normalizedFilePath); + if (binding) { + return { + connectionId: String(binding.connectionId || '').trim(), + dbName: String(binding.dbName || '').trim(), + hasExplicitBinding: true, + }; + } + } + return undefined; +}; + +export const setExternalSQLFileBinding = ( + directory: ExternalSQLDirectory, + filePath: string, + target?: { connectionId: string; dbName: string } | null, +): ExternalSQLDirectory => { + const normalizedFilePath = normalizeExternalSQLPath(filePath); + if (!normalizedFilePath) return directory; + const remainingBindings = (directory.fileBindings || []).filter( + (binding) => normalizeExternalSQLPath(binding.filePath) !== normalizedFilePath, + ); + const connectionId = String(target?.connectionId || '').trim(); + const dbName = String(target?.dbName || '').trim(); + const fileBindings = connectionId && dbName + ? [...remainingBindings, { filePath: normalizedFilePath, connectionId, dbName }] + : remainingBindings; + const nextDirectory = { ...directory }; + if (fileBindings.length > 0) { + nextDirectory.fileBindings = fileBindings; + } else { + delete nextDirectory.fileBindings; + } + return nextDirectory; +}; + +const isPathEqualOrInside = (path: string, parentPath: string): boolean => ( + path === parentPath + || (parentPath === '/' ? path.startsWith('/') : path.startsWith(`${parentPath}/`)) +); + +export const moveExternalSQLFileBindings = ( + directory: ExternalSQLDirectory, + previousPath: string, + nextPath: string, +): ExternalSQLDirectory => { + const normalizedPreviousPath = normalizeExternalSQLPath(previousPath); + const normalizedNextPath = normalizeExternalSQLPath(nextPath); + if (!normalizedPreviousPath || !normalizedNextPath || !directory.fileBindings?.length) { + return directory; + } + let changed = false; + const fileBindings = directory.fileBindings.map((binding) => { + const normalizedBindingPath = normalizeExternalSQLPath(binding.filePath); + if (!isPathEqualOrInside(normalizedBindingPath, normalizedPreviousPath)) { + return binding; + } + changed = true; + return { + ...binding, + filePath: `${normalizedNextPath}${normalizedBindingPath.slice(normalizedPreviousPath.length)}`, + }; + }); + return changed ? { ...directory, fileBindings } : directory; +}; + +export const removeExternalSQLFileBindings = ( + directory: ExternalSQLDirectory, + targetPath: string, +): ExternalSQLDirectory => { + const normalizedTargetPath = normalizeExternalSQLPath(targetPath); + if (!normalizedTargetPath || !directory.fileBindings?.length) return directory; + const fileBindings = directory.fileBindings.filter((binding) => ( + !isPathEqualOrInside(normalizeExternalSQLPath(binding.filePath), normalizedTargetPath) + )); + if (fileBindings.length === directory.fileBindings.length) return directory; + const nextDirectory = { ...directory }; + if (fileBindings.length > 0) { + nextDirectory.fileBindings = fileBindings; + } else { + delete nextDirectory.fileBindings; + } + return nextDirectory; +}; + const DEFAULT_EXTERNAL_SQL_TREE_LABELS: ExternalSQLTreeLabels = { root: 'External SQL files', directoryFallback: 'SQL directory', @@ -87,7 +226,13 @@ const isExternalSQLFileEntry = (entry: ExternalSQLTreeEntry): boolean => { const mapExternalSQLTreeEntries = ( entries: ExternalSQLTreeEntry[], - context: { connectionId: string; dbName: string; dbNodeKey: string; directoryId: string }, + context: { + connectionId: string; + dbName: string; + dbNodeKey: string; + directoryId: string; + fileBindings?: ExternalSQLFileBinding[]; + }, ): ExternalSQLTreeNode[] => entries.flatMap((entry): ExternalSQLTreeNode[] => { const entryPath = normalizeExternalSQLPath(entry.path); if (entry.isDir) { @@ -113,14 +258,23 @@ const mapExternalSQLTreeEntries = ( return []; } + const fileBinding = findExternalSQLFileBinding(context.fileBindings, entry.path); + const connectionId = String(fileBinding?.connectionId || '').trim() || context.connectionId; + const dbName = String(fileBinding?.dbName || '').trim() || context.dbName; + return [{ title: entry.name, key: buildExternalSQLNodeKey('external-sql-file', entryPath, context.directoryId), type: 'external-sql-file', isLeaf: true, dataRef: { - connectionId: context.connectionId, - dbName: context.dbName, + connectionId, + dbName, + ...(fileBinding ? { + directoryConnectionId: context.connectionId, + directoryDbName: context.dbName, + hasExplicitBinding: true, + } : {}), dbNodeKey: context.dbNodeKey, directoryId: context.directoryId, path: entry.path, @@ -154,6 +308,7 @@ export const buildExternalSQLRootNode = ({ dbName: directoryDbName, dbNodeKey, directoryId: directory.id, + fileBindings: directory.fileBindings, }); return { title: resolveDirectoryDisplayName(directory, resolvedLabels), diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 93e81776..89b47885 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -7315,6 +7315,8 @@ "sidebar.error.unknown": "Unbekannter Fehler", "sidebar.external_sql.directory_fallback": "SQL-Verzeichnis", "sidebar.external_sql.root": "Externe SQL-Dateien", + "sidebar.external_sql_binding.clear_override": "Dateispezifische Bindung entfernen", + "sidebar.external_sql_binding.title": "Ausführungsdatenbank für SQL-Datei auswählen", "sidebar.external_sql_modal.action.create": "Erstellen", "sidebar.external_sql_modal.action.rename": "Umbenennen", "sidebar.external_sql_modal.field.directory_name": "Verzeichnisname", @@ -7369,6 +7371,7 @@ "sidebar.menu.backup_all_tables_sql": "Alle Tabellen sichern (Schema + Daten-SQL)", "sidebar.menu.backup_current_schema_sql": "Alle Tabellen im aktuellen schema sichern (Struktur und Daten SQL)", "sidebar.menu.backup_table_sql": "Tabelle sichern (SQL)", + "sidebar.menu.bind_sql_file_database": "Ausführungsdatenbank auswählen", "sidebar.menu.bind_to_connection": "An Verbindung binden", "sidebar.menu.browse_keys": "Schlüssel durchsuchen", "sidebar.menu.browse_materialized_view_data": "Daten der materialisierten Ansicht durchsuchen", @@ -7525,6 +7528,8 @@ "sidebar.message.external_sql_directory_rename_sync_failed": "Das Verzeichnis wurde umbenannt, aber die externe SQL-Verzeichnisliste konnte nicht synchronisiert werden. Fügen Sie das Verzeichnis erneut hinzu.", "sidebar.message.external_sql_directory_rename_target_missing": "Das umzubenennende Verzeichnis wurde nicht gefunden.", "sidebar.message.external_sql_file_delete_target_missing": "Die zu löschende SQL-Datei wurde nicht gefunden.", + "sidebar.message.external_sql_file_binding_cleared": "Die dateispezifische Bindung wurde entfernt. Der Standardkontext des Verzeichnisses wird verwendet.", + "sidebar.message.external_sql_file_binding_saved": "Ausführungsdatenbank der SQL-Datei gespeichert.", "sidebar.message.external_sql_file_parent_missing": "Das Verzeichnis zum Erstellen der SQL-Datei wurde nicht gefunden.", "sidebar.message.external_sql_file_rename_target_missing": "Die umzubenennende SQL-Datei wurde nicht gefunden.", "sidebar.message.jvm_provider_probe_exception": "Prüfung der JVM-Anbieter fehlgeschlagen: {{error}}", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 44c0dc40..18bb1b30 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -7315,6 +7315,8 @@ "sidebar.error.unknown": "Unknown error", "sidebar.external_sql.directory_fallback": "SQL directory", "sidebar.external_sql.root": "External SQL files", + "sidebar.external_sql_binding.clear_override": "Clear file-specific binding", + "sidebar.external_sql_binding.title": "Choose SQL file execution database", "sidebar.external_sql_modal.action.create": "Create", "sidebar.external_sql_modal.action.rename": "Rename", "sidebar.external_sql_modal.field.directory_name": "Directory name", @@ -7369,6 +7371,7 @@ "sidebar.menu.backup_all_tables_sql": "Back up all tables (schema + data SQL)", "sidebar.menu.backup_current_schema_sql": "Back up all tables in current schema (schema and data SQL)", "sidebar.menu.backup_table_sql": "Back up table (SQL)", + "sidebar.menu.bind_sql_file_database": "Choose execution database", "sidebar.menu.bind_to_connection": "Bind to connection", "sidebar.menu.browse_keys": "Browse keys", "sidebar.menu.browse_materialized_view_data": "Browse materialized view data", @@ -7525,6 +7528,8 @@ "sidebar.message.external_sql_directory_rename_sync_failed": "Directory was renamed, but the external SQL directory list could not be synchronized. Add the directory again.", "sidebar.message.external_sql_directory_rename_target_missing": "Could not find the directory to rename.", "sidebar.message.external_sql_file_delete_target_missing": "Could not find the SQL file to delete.", + "sidebar.message.external_sql_file_binding_cleared": "The file-specific binding was cleared. The directory default context will be used.", + "sidebar.message.external_sql_file_binding_saved": "SQL file execution database saved.", "sidebar.message.external_sql_file_parent_missing": "Could not find the directory for creating an SQL file.", "sidebar.message.external_sql_file_rename_target_missing": "Could not find the SQL file to rename.", "sidebar.message.jvm_provider_probe_exception": "JVM provider probe failed: {{error}}", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index e2a49a1c..c27e5656 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -7315,6 +7315,8 @@ "sidebar.error.unknown": "不明なエラー", "sidebar.external_sql.directory_fallback": "SQL ディレクトリ", "sidebar.external_sql.root": "外部 SQL ファイル", + "sidebar.external_sql_binding.clear_override": "ファイル固有のバインドを解除", + "sidebar.external_sql_binding.title": "SQL ファイルの実行データベースを指定", "sidebar.external_sql_modal.action.create": "作成", "sidebar.external_sql_modal.action.rename": "名前を変更", "sidebar.external_sql_modal.field.directory_name": "ディレクトリ名", @@ -7369,6 +7371,7 @@ "sidebar.menu.backup_all_tables_sql": "すべてのテーブルをバックアップ(スキーマ + データ SQL)", "sidebar.menu.backup_current_schema_sql": "現在の schema の全テーブルをバックアップ(構造とデータ SQL)", "sidebar.menu.backup_table_sql": "テーブルをバックアップ(SQL)", + "sidebar.menu.bind_sql_file_database": "実行データベースを指定", "sidebar.menu.bind_to_connection": "接続にバインド", "sidebar.menu.browse_keys": "キーを参照", "sidebar.menu.browse_materialized_view_data": "マテリアライズドビューのデータを参照", @@ -7525,6 +7528,8 @@ "sidebar.message.external_sql_directory_rename_sync_failed": "ディレクトリの名前は変更されましたが、外部 SQL ディレクトリ一覧を同期できません。ディレクトリを追加し直してください。", "sidebar.message.external_sql_directory_rename_target_missing": "名前を変更できるディレクトリが見つかりません。", "sidebar.message.external_sql_file_delete_target_missing": "削除する SQL ファイルが見つかりません。", + "sidebar.message.external_sql_file_binding_cleared": "ファイル固有のバインドを解除しました。ディレクトリの既定コンテキストを使用します。", + "sidebar.message.external_sql_file_binding_saved": "SQL ファイルの実行データベースを保存しました。", "sidebar.message.external_sql_file_parent_missing": "SQL ファイルを作成するディレクトリが見つかりません。", "sidebar.message.external_sql_file_rename_target_missing": "名前を変更できる SQL ファイルが見つかりません。", "sidebar.message.jvm_provider_probe_exception": "JVM プロバイダーの検出に失敗しました: {{error}}", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index e7969b3f..6c62ce2a 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -7315,6 +7315,8 @@ "sidebar.error.unknown": "Неизвестная ошибка", "sidebar.external_sql.directory_fallback": "SQL-каталог", "sidebar.external_sql.root": "Внешние SQL-файлы", + "sidebar.external_sql_binding.clear_override": "Удалить отдельную привязку файла", + "sidebar.external_sql_binding.title": "Выбор базы выполнения SQL-файла", "sidebar.external_sql_modal.action.create": "Создать", "sidebar.external_sql_modal.action.rename": "Переименовать", "sidebar.external_sql_modal.field.directory_name": "Имя каталога", @@ -7369,6 +7371,7 @@ "sidebar.menu.backup_all_tables_sql": "Создать резервную копию всех таблиц (схема + данные SQL)", "sidebar.menu.backup_current_schema_sql": "Создать резервную копию всех таблиц текущей schema (структура и данные SQL)", "sidebar.menu.backup_table_sql": "Создать резервную копию таблицы (SQL)", + "sidebar.menu.bind_sql_file_database": "Выбрать базу выполнения", "sidebar.menu.bind_to_connection": "Привязать к подключению", "sidebar.menu.browse_keys": "Просмотреть ключи", "sidebar.menu.browse_materialized_view_data": "Просмотреть данные материализованного представления", @@ -7525,6 +7528,8 @@ "sidebar.message.external_sql_directory_rename_sync_failed": "Каталог переименован, но список внешних SQL-каталогов не удалось синхронизировать. Добавьте каталог заново.", "sidebar.message.external_sql_directory_rename_target_missing": "Не найден каталог для переименования.", "sidebar.message.external_sql_file_delete_target_missing": "SQL-файл для удаления не найден.", + "sidebar.message.external_sql_file_binding_cleared": "Отдельная привязка файла удалена. Будет использован контекст каталога по умолчанию.", + "sidebar.message.external_sql_file_binding_saved": "База выполнения SQL-файла сохранена.", "sidebar.message.external_sql_file_parent_missing": "Не найден каталог для создания SQL-файла.", "sidebar.message.external_sql_file_rename_target_missing": "Не найден SQL-файл для переименования.", "sidebar.message.jvm_provider_probe_exception": "Проверка провайдеров JVM завершилась ошибкой: {{error}}", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 04c1ed12..ac21c2c9 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -7315,6 +7315,8 @@ "sidebar.error.unknown": "未知错误", "sidebar.external_sql.directory_fallback": "SQL 目录", "sidebar.external_sql.root": "外部 SQL 文件", + "sidebar.external_sql_binding.clear_override": "清除文件单独绑定", + "sidebar.external_sql_binding.title": "指定 SQL 文件执行数据库", "sidebar.external_sql_modal.action.create": "新建", "sidebar.external_sql_modal.action.rename": "重命名", "sidebar.external_sql_modal.field.directory_name": "目录名", @@ -7369,6 +7371,7 @@ "sidebar.menu.backup_all_tables_sql": "备份全部表(结构和数据 SQL)", "sidebar.menu.backup_current_schema_sql": "备份当前模式全部表(结构和数据 SQL)", "sidebar.menu.backup_table_sql": "备份表(SQL)", + "sidebar.menu.bind_sql_file_database": "指定执行数据库", "sidebar.menu.bind_to_connection": "绑定到连接", "sidebar.menu.browse_keys": "浏览键", "sidebar.menu.browse_materialized_view_data": "浏览物化视图数据", @@ -7525,6 +7528,8 @@ "sidebar.message.external_sql_directory_rename_sync_failed": "目录已重命名,但无法同步外部 SQL 目录列表,请重新添加目录。", "sidebar.message.external_sql_directory_rename_target_missing": "未找到可重命名的目录。", "sidebar.message.external_sql_file_delete_target_missing": "未找到可删除的 SQL 文件。", + "sidebar.message.external_sql_file_binding_cleared": "已清除 SQL 文件单独绑定,将使用目录默认上下文。", + "sidebar.message.external_sql_file_binding_saved": "SQL 文件执行数据库已保存。", "sidebar.message.external_sql_file_parent_missing": "未找到可新建 SQL 文件的目录。", "sidebar.message.external_sql_file_rename_target_missing": "未找到可重命名的 SQL 文件。", "sidebar.message.jvm_provider_probe_exception": "JVM 提供方探测失败:{{error}}", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 49f0555d..76647d6e 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -7315,6 +7315,8 @@ "sidebar.error.unknown": "未知錯誤", "sidebar.external_sql.directory_fallback": "SQL 目錄", "sidebar.external_sql.root": "外部 SQL 檔案", + "sidebar.external_sql_binding.clear_override": "清除檔案單獨綁定", + "sidebar.external_sql_binding.title": "指定 SQL 檔案執行資料庫", "sidebar.external_sql_modal.action.create": "新增", "sidebar.external_sql_modal.action.rename": "重新命名", "sidebar.external_sql_modal.field.directory_name": "目錄名稱", @@ -7369,6 +7371,7 @@ "sidebar.menu.backup_all_tables_sql": "備份全部資料表(結構和資料 SQL)", "sidebar.menu.backup_current_schema_sql": "備份目前模式全部資料表(結構和資料 SQL)", "sidebar.menu.backup_table_sql": "備份資料表(SQL)", + "sidebar.menu.bind_sql_file_database": "指定執行資料庫", "sidebar.menu.bind_to_connection": "綁定到連線", "sidebar.menu.browse_keys": "瀏覽鍵", "sidebar.menu.browse_materialized_view_data": "瀏覽物化檢視資料", @@ -7525,6 +7528,8 @@ "sidebar.message.external_sql_directory_rename_sync_failed": "目錄已重新命名,但無法同步外部 SQL 目錄清單,請重新新增目錄。", "sidebar.message.external_sql_directory_rename_target_missing": "找不到可重新命名的目錄。", "sidebar.message.external_sql_file_delete_target_missing": "找不到可刪除的 SQL 檔案。", + "sidebar.message.external_sql_file_binding_cleared": "已清除 SQL 檔案單獨綁定,將使用目錄預設內容。", + "sidebar.message.external_sql_file_binding_saved": "SQL 檔案執行資料庫已儲存。", "sidebar.message.external_sql_file_parent_missing": "找不到可新增 SQL 檔案的目錄。", "sidebar.message.external_sql_file_rename_target_missing": "找不到可重新命名的 SQL 檔案。", "sidebar.message.jvm_provider_probe_exception": "JVM 提供者探測失敗:{{error}}",