import React, { useState, useEffect, useRef, useMemo } from 'react'; import Editor, { OnMount } from '@monaco-editor/react'; import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd'; import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined } from '@ant-design/icons'; import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQuery, DBQueryWithCancel, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { convertMongoShellToJsonCommand } from '../utils/mongodb'; import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts'; const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); type ResultSet = { key: string; sql: string; exportSql?: string; rows: any[]; columns: string[]; tableName?: string; pkColumns: string[]; readOnly: boolean; truncated?: boolean; pkLoading?: boolean; }; // Result Sets const [resultSets, setResultSets] = useState([]); const [activeResultKey, setActiveResultKey] = useState(''); const [loading, setLoading] = useState(false); const [currentQueryId, setCurrentQueryId] = useState(''); const runSeqRef = useRef(0); const currentQueryIdRef = useRef(''); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); const [saveForm] = Form.useForm(); // Database Selection const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId); const [currentDb, setCurrentDb] = useState(tab.dbName || ''); const [dbList, setDbList] = useState([]); // Resizing state const [editorHeight, setEditorHeight] = useState(300); const editorRef = useRef(null); const monacoRef = useRef(null); const lastExternalQueryRef = useRef(tab.query || ''); const dragRef = useRef<{ startY: number, startHeight: number } | null>(null); const tablesRef = useRef<{dbName: string, tableName: string}[]>([]); // Store tables for autocomplete (cross-db) const allColumnsRef = useRef<{dbName: string, tableName: string, name: string, type: string}[]>([]); // Store all columns (cross-db) const visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense const connections = useStore(state => state.connections); const queryCapableConnections = useMemo( () => connections.filter(c => getDataSourceCapabilities(c.config).supportsQueryEditor), [connections] ); const addSqlLog = useStore(state => state.addSqlLog); const currentConnectionIdRef = useRef(currentConnectionId); const currentDbRef = useRef(currentDb); const connectionsRef = useRef(connections); const columnsCacheRef = useRef>({}); const saveQuery = useStore(state => state.saveQuery); const theme = useStore(state => state.theme); const darkMode = theme === 'dark'; const sqlFormatOptions = useStore(state => state.sqlFormatOptions); const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions); const queryOptions = useStore(state => state.queryOptions); const setQueryOptions = useStore(state => state.setQueryOptions); const shortcutOptions = useStore(state => state.shortcutOptions); const activeTabId = useStore(state => state.activeTabId); useEffect(() => { currentConnectionIdRef.current = currentConnectionId; }, [currentConnectionId]); useEffect(() => { if (!queryCapableConnections.some(c => c.id === currentConnectionId)) { const fallback = queryCapableConnections[0]?.id || ''; if (fallback && fallback !== currentConnectionId) { setCurrentConnectionId(fallback); setCurrentDb(''); } } }, [queryCapableConnections, currentConnectionId]); useEffect(() => { currentDbRef.current = currentDb; }, [currentDb]); useEffect(() => { connectionsRef.current = connections; }, [connections]); const getCurrentQuery = () => { const val = editorRef.current?.getValue?.(); if (typeof val === 'string') return val; return query || ''; }; const syncQueryToEditor = (sql: string) => { const next = sql || ''; setQuery(next); const editor = editorRef.current; if (editor && editor.getValue?.() !== next) { editor.setValue(next); } }; // If opening a saved query, load its SQL useEffect(() => { const incoming = tab.query || ''; if (incoming === lastExternalQueryRef.current) { return; } lastExternalQueryRef.current = incoming; syncQueryToEditor(incoming || 'SELECT * FROM '); }, [tab.id, tab.query]); // Fetch Database List useEffect(() => { const fetchDbs = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const res = await DBGetDatabases(config as any); if (res.success && Array.isArray(res.data)) { let dbs = res.data.map((row: any) => row.Database || row.database); // 过滤只显示 includeDatabases 中配置的数据库 const includeDbs = conn.includeDatabases; if (includeDbs && includeDbs.length > 0) { dbs = dbs.filter((db: string) => includeDbs.includes(db)); } // 存储可见数据库列表用于跨库智能提示 visibleDbsRef.current = dbs; setDbList(dbs); if (!currentDbRef.current) { if (conn.config.database && dbs.includes(conn.config.database)) setCurrentDb(conn.config.database); else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]); } } else { visibleDbsRef.current = []; setDbList([]); } }; fetchDbs(); }, [currentConnectionId, connections]); // Fetch Metadata for Autocomplete (Cross-database) useEffect(() => { const fetchMetadata = async () => { const conn = connections.find(c => c.id === currentConnectionId); if (!conn) return; const visibleDbs = visibleDbsRef.current; if (!visibleDbs || visibleDbs.length === 0) return; const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; // 加载所有可见数据库的表 const allTables: {dbName: string, tableName: string}[] = []; const allColumns: {dbName: string, tableName: string, name: string, type: string}[] = []; for (const dbName of visibleDbs) { // 获取表 const resTables = await DBGetTables(config as any, dbName); if (resTables.success && Array.isArray(resTables.data)) { const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string); tableNames.forEach((tableName: string) => { allTables.push({ dbName, tableName }); }); } // 获取列 (所有数据库类型都支持 DBGetAllColumns) const resCols = await DBGetAllColumns(config as any, dbName); if (resCols.success && Array.isArray(resCols.data)) { resCols.data.forEach((col: any) => { allColumns.push({ dbName, tableName: col.tableName, name: col.name, type: col.type }); }); } } tablesRef.current = allTables; allColumnsRef.current = allColumns; }; fetchMetadata(); }, [currentConnectionId, connections, dbList]); // dbList 变化时触发重新加载 // Query ID management helpers const setQueryId = (id: string) => { currentQueryIdRef.current = id; setCurrentQueryId(id); }; const clearQueryId = () => { currentQueryIdRef.current = ''; setCurrentQueryId(''); }; // Handle Resizing const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); dragRef.current = { startY: e.clientY, startHeight: editorHeight }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; const handleMouseMove = (e: MouseEvent) => { if (!dragRef.current) return; const delta = e.clientY - dragRef.current.startY; const newHeight = Math.max(100, Math.min(window.innerHeight - 200, dragRef.current.startHeight + delta)); setEditorHeight(newHeight); }; const handleMouseUp = () => { dragRef.current = null; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; // Setup Autocomplete and Editor const handleEditorDidMount: OnMount = (editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; // 应用透明主题(主题已在 main.tsx 全局注册) monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light'); monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.'], provideCompletionItems: async (model: any, position: any) => { const word = model.getWordUntilPosition(position); const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, startColumn: word.startColumn, endColumn: word.endColumn, }; const stripQuotes = (ident: string) => { let raw = (ident || '').trim(); if (!raw) return raw; const first = raw[0]; const last = raw[raw.length - 1]; if ((first === '`' && last === '`') || (first === '"' && last === '"')) { raw = raw.slice(1, -1); } return raw.trim(); }; const normalizeQualifiedName = (ident: string) => { const raw = (ident || '').trim(); if (!raw) return raw; return raw .split('.') .map(p => stripQuotes(p.trim())) .filter(Boolean) .join('.'); }; const getLastPart = (qualified: string) => { const raw = normalizeQualifiedName(qualified); if (!raw) return raw; const parts = raw.split('.').filter(Boolean); return parts[parts.length - 1] || raw; }; const splitSchemaAndTable = (qualified: string): { schema: string; table: string } => { const raw = normalizeQualifiedName(qualified); if (!raw) return { schema: '', table: '' }; const parts = raw.split('.').filter(Boolean); if (parts.length >= 2) { return { schema: parts[parts.length - 2] || '', table: parts[parts.length - 1] || '', }; } return { schema: '', table: parts[0] || '' }; }; const buildConnConfig = () => { const connId = currentConnectionIdRef.current; const conn = connectionsRef.current.find(c => c.id === connId); if (!conn) return null; return { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; }; const getColumnsByDB = async (tableIdent: string) => { const connId = currentConnectionIdRef.current; const dbName = currentDbRef.current; if (!connId || !dbName) return [] as ColumnDefinition[]; const key = `${connId}|${dbName}|${tableIdent}`; const cached = columnsCacheRef.current[key]; if (cached) return cached; const config = buildConnConfig(); if (!config) return [] as ColumnDefinition[]; const res = await DBGetColumns(config as any, dbName, tableIdent); if (res?.success && Array.isArray(res.data)) { const cols = res.data as ColumnDefinition[]; columnsCacheRef.current[key] = cols; return cols; } return [] as ColumnDefinition[]; }; const fullText = model.getValue(); // 获取当前行光标前的内容 const linePrefix = model.getLineContent(position.lineNumber).slice(0, position.column - 1); // 0) 三段式 db.table.column 格式:当输入 db.table. 时提示列 const threePartMatch = linePrefix.match(/([`"]?[\w]+[`"]?)\.([`"]?[\w]+[`"]?)\.(\w*)$/); if (threePartMatch) { const dbPart = stripQuotes(threePartMatch[1]); const tablePart = stripQuotes(threePartMatch[2]); const colPrefix = (threePartMatch[3] || '').toLowerCase(); // 在 allColumnsRef 中查找匹配的列 const cols = allColumnsRef.current.filter(c => (c.dbName || '').toLowerCase() === dbPart.toLowerCase() && (c.tableName || '').toLowerCase() === tablePart.toLowerCase() ); const filtered = colPrefix ? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix)) : cols; const suggestions = filtered.map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: c.name, detail: `${c.type} (${c.dbName}.${c.tableName})`, range, sortText: '0' + c.name })); return { suggestions }; } // 1) 两段式 qualifier.xxx 格式 const qualifierMatch = linePrefix.match(/([`"]?[A-Za-z_][\w]*[`"]?)\.(\w*)$/); if (qualifierMatch) { const qualifier = stripQuotes(qualifierMatch[1]); const prefix = (qualifierMatch[2] || '').toLowerCase(); const qualifierLower = qualifier.toLowerCase(); // 首先检查 qualifier 是否是数据库名(跨库表提示) const visibleDbs = visibleDbsRef.current; if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) { // qualifier 是数据库名,提示该库的表 const tables = tablesRef.current.filter(t => (t.dbName || '').toLowerCase() === qualifierLower ); const filtered = prefix ? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix)) : tables; const suggestions = filtered.map(t => ({ label: t.tableName, kind: monaco.languages.CompletionItemKind.Class, insertText: t.tableName, detail: `Table (${t.dbName})`, range, sortText: '0' + t.tableName })); return { suggestions }; } // qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table const schemaTables = tablesRef.current .map(t => { const parsed = splitSchemaAndTable(t.tableName || ''); return { dbName: t.dbName || '', schema: parsed.schema, table: parsed.table, }; }) .filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table); if (schemaTables.length > 0) { const filtered = prefix ? schemaTables.filter(t => t.table.toLowerCase().startsWith(prefix)) : schemaTables; const suggestions = filtered.map(t => ({ label: t.table, kind: monaco.languages.CompletionItemKind.Class, insertText: t.table, detail: `Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, range, sortText: '0' + t.table })); return { suggestions }; } // 否则检查是否是表别名或表名,提示列 const reserved = new Set([ 'where', 'on', 'group', 'order', 'limit', 'having', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'join', 'union', 'except', 'intersect', 'as', 'set', 'values', 'returning', ]); const aliasMap: Record = {}; // Capture table and optional alias, support db.table format const aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?[\w]+[`"]?))?/gi; let m; while ((m = aliasRegex.exec(fullText)) !== null) { const tableIdent = normalizeQualifiedName(m[1] || ''); if (!tableIdent) continue; // 解析 db.table 或 table 格式 const parts = tableIdent.split('.'); let dbName = currentDbRef.current || ''; let tableName = tableIdent; if (parts.length === 2) { dbName = parts[0]; tableName = parts[1]; } const shortTable = getLastPart(tableIdent); // 用表名作为 qualifier if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName }; const a = stripQuotes(m[2] || '').trim(); if (!a) continue; const al = a.toLowerCase(); if (reserved.has(al)) continue; aliasMap[al] = { dbName, tableName }; } const tableInfo = aliasMap[qualifier.toLowerCase()]; if (tableInfo) { // Prefer preloaded MySQL all-columns cache let cols: { name: string, type?: string, tableName?: string, dbName?: string }[] = []; if (allColumnsRef.current.length > 0) { cols = allColumnsRef.current .filter(c => (c.dbName || '').toLowerCase() === (tableInfo.dbName || '').toLowerCase() && (c.tableName || '').toLowerCase() === (tableInfo.tableName || '').toLowerCase() ) .map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName })); } else { const dbCols = await getColumnsByDB(tableInfo.tableName); cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName })); } const filtered = prefix ? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix)) : cols; const suggestions = filtered.map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: c.name, detail: c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''), range, sortText: '0' + c.name })); return { suggestions }; } } // 2) global/table/column completion const tableRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?[\w]+[`"]?(?:\s*\.\s*[`"]?[\w]+[`"]?)?)/gi; const foundTables = new Set(); let match; while ((match = tableRegex.exec(fullText)) !== null) { const t = normalizeQualifiedName(match[1] || ''); if (!t) continue; // 存储完整标识 db.table 或 table foundTables.add(t.toLowerCase()); } const currentDatabase = currentDbRef.current || ''; // 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等) // 权重最高,输入 WHERE 条件时优先显示 const relevantColumns = allColumnsRef.current .filter(c => { const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase(); const shortIdent = (c.tableName || '').toLowerCase(); return foundTables.has(fullIdent) || foundTables.has(shortIdent); }) .map(c => { // 当前库的表字段优先级更高 const isCurrentDb = (c.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); return { label: c.name, kind: monaco.languages.CompletionItemKind.Field, insertText: c.name, detail: `${c.type} (${c.dbName}.${c.tableName})`, range, sortText: isCurrentDb ? '00' + c.name : '01' + c.name // FROM 表字段最优先 }; }); // 表提示:当前库显示表名,其他库显示 db.table 格式 const tableSuggestions = tablesRef.current.map(t => { const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase(); const label = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; const insertText = isCurrentDb ? t.tableName : `${t.dbName}.${t.tableName}`; return { label, kind: monaco.languages.CompletionItemKind.Class, insertText, detail: `Table (${t.dbName})`, range, sortText: isCurrentDb ? '10' + t.tableName : '11' + t.tableName // 表次优先 }; }); // 数据库提示 const dbSuggestions = visibleDbsRef.current.map(db => ({ label: db, kind: monaco.languages.CompletionItemKind.Module, insertText: db, detail: 'Database', range, sortText: '20' + db // 数据库最后 })); // 关键字提示 const keywordSuggestions = ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({ label: k, kind: monaco.languages.CompletionItemKind.Keyword, insertText: k, range, sortText: '30' + k // 关键字权重最低 })); const suggestions = [ ...relevantColumns, // FROM 表的列最优先 ...tableSuggestions, // 表次之 ...dbSuggestions, // 数据库 ...keywordSuggestions // 关键字最后 ]; return { suggestions }; } }); }; const handleFormat = () => { try { const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase }); syncQueryToEditor(formatted); } catch (e) { message.error("格式化失败: SQL 语法可能有误"); } }; const formatSettingsMenu: MenuProps['items'] = [ { key: 'upper', label: '关键字大写', icon: sqlFormatOptions.keywordCase === 'upper' ? '✓' : undefined, onClick: () => setSqlFormatOptions({ keywordCase: 'upper' }) }, { key: 'lower', label: '关键字小写', icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined, onClick: () => setSqlFormatOptions({ keywordCase: 'lower' }) }, { type: 'divider' }, { key: 'shortcut-settings', label: '快捷键管理...', onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-shortcut-settings')), }, ]; const splitSQLStatements = (sql: string): string[] => { const text = (sql || '').replace(/\r\n/g, '\n'); const statements: string[] = []; let cur = ''; let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; // postgres/kingbase: $$...$$ or $tag$...$tag$ const push = () => { const s = cur.trim(); if (s) statements.push(s); cur = ''; }; const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (inLineComment) { cur += ch; if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { cur += ch; if (ch === '*' && next === '/') { cur += next; i++; inBlockComment = false; } continue; } // Start comments if (ch === '/' && next === '*') { cur += ch + next; i++; inBlockComment = true; continue; } if (ch === '#') { cur += ch; inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { cur += ch + next; i++; inLineComment = true; continue; } // Dollar-quoted strings (PG/Kingbase) if (dollarTag) { if (text.startsWith(dollarTag, i)) { cur += dollarTag; i += dollarTag.length - 1; dollarTag = null; } else { cur += ch; } continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; cur += dollarTag; i += dollarTag.length - 1; continue; } } } if (escaped) { cur += ch; escaped = false; continue; } if ((inSingle || inDouble) && ch === '\\') { cur += ch; escaped = true; continue; } if (!inDouble && !inBacktick && ch === '\'') { inSingle = !inSingle; cur += ch; continue; } if (!inSingle && !inBacktick && ch === '"') { inDouble = !inDouble; cur += ch; continue; } if (!inSingle && !inDouble && ch === '`') { inBacktick = !inBacktick; cur += ch; continue; } if (!inSingle && !inDouble && !inBacktick && !dollarTag && (ch === ';' || ch === ';')) { push(); continue; } cur += ch; } push(); return statements; }; const getLeadingKeyword = (sql: string): string => { const text = (sql || '').replace(/\r\n/g, '\n'); const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch); let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (inLineComment) { if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { if (ch === '*' && next === '/') { i++; inBlockComment = false; } continue; } if (ch === '/' && next === '*') { i++; inBlockComment = true; continue; } if (ch === '#') { inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { i++; inLineComment = true; continue; } if (dollarTag) { if (text.startsWith(dollarTag, i)) { i += dollarTag.length - 1; dollarTag = null; } continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; i += dollarTag.length - 1; continue; } } } if (escaped) { escaped = false; continue; } if ((inSingle || inDouble) && ch === '\\') { escaped = true; continue; } if (!inDouble && !inBacktick && ch === '\'') { inSingle = !inSingle; continue; } if (!inSingle && !inBacktick && ch === '"') { inDouble = !inDouble; continue; } if (!inSingle && !inDouble && ch === '`') { inBacktick = !inBacktick; continue; } if (inSingle || inDouble || inBacktick || dollarTag) continue; if (isWS(ch)) continue; if (isWord(ch)) { let j = i; while (j < text.length && isWord(text[j])) j++; return text.slice(i, j).toLowerCase(); } return ''; } return ''; }; const splitSqlTail = (sql: string): { main: string; tail: string } => { const text = (sql || '').replace(/\r\n/g, '\n'); const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; let lastMeaningful = -1; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (dollarTag) { if (text.startsWith(dollarTag, i)) { lastMeaningful = i + dollarTag.length - 1; i += dollarTag.length - 1; dollarTag = null; } else if (!isWS(ch)) { lastMeaningful = i; } continue; } if (inLineComment) { if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { if (ch === '*' && next === '/') { i++; inBlockComment = false; } continue; } // Start comments if (ch === '/' && next === '*') { i++; inBlockComment = true; continue; } if (ch === '#') { inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { i++; inLineComment = true; continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; lastMeaningful = i + dollarTag.length - 1; i += dollarTag.length - 1; continue; } } } if (escaped) { escaped = false; } else if ((inSingle || inDouble) && ch === '\\') { escaped = true; } else { if (!inDouble && !inBacktick && ch === '\'') inSingle = !inSingle; else if (!inSingle && !inBacktick && ch === '"') inDouble = !inDouble; else if (!inSingle && !inDouble && ch === '`') inBacktick = !inBacktick; } if (!inLineComment && !inBlockComment && !isWS(ch)) { lastMeaningful = i; } } if (lastMeaningful < 0) return { main: '', tail: text }; return { main: text.slice(0, lastMeaningful + 1), tail: text.slice(lastMeaningful + 1) }; }; const findTopLevelKeyword = (sql: string, keyword: string): number => { const text = sql; const kw = keyword.toLowerCase(); const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch); let inSingle = false; let inDouble = false; let inBacktick = false; let escaped = false; let inLineComment = false; let inBlockComment = false; let dollarTag: string | null = null; let parenDepth = 0; for (let i = 0; i < text.length; i++) { const ch = text[i]; const next = i + 1 < text.length ? text[i + 1] : ''; const prev = i > 0 ? text[i - 1] : ''; const next2 = i + 2 < text.length ? text[i + 2] : ''; if (!inSingle && !inDouble && !inBacktick) { if (inLineComment) { if (ch === '\n') inLineComment = false; continue; } if (inBlockComment) { if (ch === '*' && next === '/') { i++; inBlockComment = false; } continue; } if (ch === '/' && next === '*') { i++; inBlockComment = true; continue; } if (ch === '#') { inLineComment = true; continue; } if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { i++; inLineComment = true; continue; } if (dollarTag) { if (text.startsWith(dollarTag, i)) { i += dollarTag.length - 1; dollarTag = null; } continue; } if (ch === '$') { const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); if (m && m[0]) { dollarTag = m[0]; i += dollarTag.length - 1; continue; } } } if (escaped) { escaped = false; continue; } if ((inSingle || inDouble) && ch === '\\') { escaped = true; continue; } if (!inDouble && !inBacktick && ch === '\'') { inSingle = !inSingle; continue; } if (!inSingle && !inBacktick && ch === '"') { inDouble = !inDouble; continue; } if (!inSingle && !inDouble && ch === '`') { inBacktick = !inBacktick; continue; } if (inSingle || inDouble || inBacktick || dollarTag) continue; if (ch === '(') { parenDepth++; continue; } if (ch === ')') { if (parenDepth > 0) parenDepth--; continue; } if (parenDepth !== 0) continue; if (!isWord(ch)) continue; if (text.slice(i, i + kw.length).toLowerCase() !== kw) continue; const before = i - 1 >= 0 ? text[i - 1] : ''; const after = i + kw.length < text.length ? text[i + kw.length] : ''; if ((before && isWord(before)) || (after && isWord(after))) continue; return i; } return -1; }; const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => { const normalizedType = (dbType || 'mysql').toLowerCase(); const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === 'clickhouse' || normalizedType === ''; if (!supportsLimit) return { sql, applied: false, maxRows }; if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows }; const { main, tail } = splitSqlTail(sql); if (!main.trim()) return { sql, applied: false, maxRows }; const fromPos = findTopLevelKeyword(main, 'from'); const limitPos = findTopLevelKeyword(main, 'limit'); if (limitPos >= 0 && (fromPos < 0 || limitPos > fromPos)) return { sql, applied: false, maxRows }; const fetchPos = findTopLevelKeyword(main, 'fetch'); if (fetchPos >= 0 && (fromPos < 0 || fetchPos > fromPos)) return { sql, applied: false, maxRows }; const offsetPos = findTopLevelKeyword(main, 'offset'); const forPos = findTopLevelKeyword(main, 'for'); const lockPos = findTopLevelKeyword(main, 'lock'); const candidates = [offsetPos, forPos, lockPos] .filter(pos => pos >= 0 && (fromPos < 0 || pos > fromPos)); const insertAt = candidates.length > 0 ? Math.min(...candidates) : main.length; const before = main.slice(0, insertAt).trimEnd(); const after = main.slice(insertAt).trimStart(); const nextMain = [before, `LIMIT ${maxRows}`, after].filter(Boolean).join(' ').trim(); return { sql: nextMain + tail, applied: true, maxRows }; }; const getSelectedSQL = (): string => { const editor = editorRef.current; if (!editor) return ''; const model = editor.getModel?.(); const selection = editor.getSelection?.(); if (!model || !selection) return ''; const selected = model.getValueInRange?.(selection) || ''; if (typeof selected !== 'string') return ''; if (!selected.trim()) return ''; return selected; }; const handleRun = async () => { const currentQuery = getCurrentQuery(); if (!currentQuery.trim()) return; if (!currentDb) { message.error("请先选择数据库"); return; } // 如果已有查询在运行,先取消它 if (currentQueryIdRef.current) { try { await CancelQuery(currentQueryIdRef.current); } catch (error) { // 忽略取消错误,可能查询已完成 } // 清除旧查询ID clearQueryId(); } const runSeq = ++runSeqRef.current; setLoading(true); const runStartTime = Date.now(); const conn = connections.find(c => c.id === currentConnectionId); if (!conn) { message.error("Connection not found"); if (runSeqRef.current === runSeq) setLoading(false); return; } const connCaps = getDataSourceCapabilities(conn.config); if (!connCaps.supportsQueryEditor) { message.error("当前数据源不支持 SQL 查询编辑器,请使用对应专用页面。"); if (runSeqRef.current === runSeq) setLoading(false); return; } const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: conn.config.database || "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; try { const rawSQL = getSelectedSQL() || currentQuery; const dbType = String((config as any).type || 'mysql'); const normalizedDbType = dbType.trim().toLowerCase(); const normalizedRawSQL = String(rawSQL || '').replace(/;/g, ';'); const splitInput = normalizedDbType === 'mongodb' ? normalizedRawSQL .replace(/^\s*\/\/.*$/gm, '') .replace(/^\s*#.*$/gm, '') : normalizedRawSQL; const statements = splitSQLStatements(splitInput); if (statements.length === 0) { message.info('没有可执行的 SQL。'); setResultSets([]); setActiveResultKey(''); return; } const nextResultSets: ResultSet[] = []; const maxRows = Number(queryOptions?.maxRows) || 0; const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0; const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0; let anyTruncated = false; const pendingPk: Array<{ resultKey: string; tableName: string }> = []; for (let idx = 0; idx < statements.length; idx++) { const rawStatement = statements[idx]; const leadingKeyword = getLeadingKeyword(rawStatement); const shouldAutoLimit = leadingKeyword === 'select' || leadingKeyword === 'with'; const limitApplied = shouldAutoLimit && wantsLimitProbe; const limited = limitApplied ? applyAutoLimit(rawStatement, dbType, probeLimit) : { sql: rawStatement, applied: false, maxRows: probeLimit }; let executedSql = limited.sql; if (String(dbType || '').trim().toLowerCase() === 'mongodb') { const shellConvert = convertMongoShellToJsonCommand(executedSql); if (shellConvert.recognized) { if (shellConvert.error) { const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; message.error(prefix + shellConvert.error); setResultSets([]); setActiveResultKey(''); return; } if (shellConvert.command) { executedSql = shellConvert.command; } } } const startTime = Date.now(); // Generate query ID for cancellation using backend UUID with fallback let queryId: string; try { queryId = await GenerateQueryID(); } catch (error) { console.warn('GenerateQueryID failed, using local UUID fallback:', error); queryId = 'query-' + uuidv4(); } setQueryId(queryId); const res = await DBQueryWithCancel(config as any, currentDb, executedSql, queryId); const duration = Date.now() - startTime; addSqlLog({ id: `log-${Date.now()}-query-${idx + 1}`, timestamp: Date.now(), sql: executedSql, status: res.success ? 'success' : 'error', duration, message: res.success ? '' : res.message, affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined), dbName: currentDb }); if (!res.success) { // 检查是否为查询取消错误 const errorMsg = res.message.toLowerCase(); const isCancelledError = errorMsg.includes('context canceled') || errorMsg.includes('查询已取消') || errorMsg.includes('canceled') || errorMsg.includes('cancelled') || errorMsg.includes('statement canceled') || errorMsg.includes('sql: statement canceled'); // 确保不是超时错误 const isTimeoutError = errorMsg.includes('context deadline exceeded') || errorMsg.includes('timeout') || errorMsg.includes('超时') || errorMsg.includes('deadline exceeded'); if (isCancelledError && !isTimeoutError) { // 查询已被用户取消,不显示错误消息,清理状态 setResultSets([]); setActiveResultKey(''); // 清除查询ID,与handleCancel保持一致 if (currentQueryIdRef.current) { clearQueryId(); } return; } const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; message.error(prefix + res.message); setResultSets([]); setActiveResultKey(''); return; } if (Array.isArray(res.data)) { let rows = (res.data as any[]) || []; let truncated = false; if (limited.applied && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) { truncated = true; anyTruncated = true; rows = rows.slice(0, maxRows); } const cols = (res.fields && res.fields.length > 0) ? (res.fields as string[]) : (rows.length > 0 ? Object.keys(rows[0]) : []); rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); let simpleTableName: string | undefined = undefined; const tableMatch = rawStatement.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); if (tableMatch) { simpleTableName = tableMatch[1]; if (!forceReadOnlyResult) { pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName }); } } nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: limited.applied ? applyAutoLimit(rawStatement, dbType, Math.max(1, Number(maxRows) || 1)).sql : rawStatement, rows, columns: cols, tableName: simpleTableName, pkColumns: [], readOnly: true, pkLoading: !!simpleTableName, truncated }); } else { const affected = Number((res.data as any)?.affectedRows); if (Number.isFinite(affected)) { const row = { affectedRows: affected }; (row as any)[GONAVI_ROW_KEY] = 0; nextResultSets.push({ key: `result-${idx + 1}`, sql: rawStatement, exportSql: rawStatement, rows: [row], columns: ['affectedRows'], pkColumns: [], readOnly: true }); } } } setResultSets(nextResultSets); setActiveResultKey(nextResultSets[0]?.key || ''); pendingPk.forEach(({ resultKey, tableName }) => { DBGetColumns(config as any, currentDb, tableName) .then((resCols: any) => { if (runSeqRef.current !== runSeq) return; if (!resCols?.success) { setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs)); return; } const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name); setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs)); }) .catch(() => { if (runSeqRef.current !== runSeq) return; setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs)); }); }); if (statements.length > 1) { message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`); } else if (nextResultSets.length === 0) { message.success('执行成功。'); } if (anyTruncated && maxRows > 0) { message.warning(`结果集已自动限制为最多 ${maxRows} 行(可在工具栏调整)。`); } } catch (e: any) { message.error("Error executing query: " + e.message); addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), sql: getSelectedSQL() || query, status: 'error', duration: Date.now() - runStartTime, message: e.message, dbName: currentDb }); setResultSets([]); setActiveResultKey(''); } finally { if (runSeqRef.current === runSeq) setLoading(false); // Clear query ID after execution completes clearQueryId(); } }; const handleCancel = async () => { if (!currentQueryIdRef.current) { message.warning('没有正在运行的查询可取消'); return; } const queryIdToCancel = currentQueryIdRef.current; try { const res = await CancelQuery(queryIdToCancel); if (res.success) { message.success('查询已取消'); // Clear query ID after successful cancellation if (currentQueryIdRef.current === queryIdToCancel) { clearQueryId() } } else { message.warning(res.message); } } catch (error: any) { message.error('取消查询失败: ' + error.message); } }; useEffect(() => { const binding = shortcutOptions.runQuery; if (!binding?.enabled || !binding.combo) { return; } const handleRunShortcut = (event: KeyboardEvent) => { if (activeTabId !== tab.id) { return; } if (!isShortcutMatch(event, binding.combo)) { return; } const editorHasFocus = !!editorRef.current?.hasTextFocus?.(); if (!editorHasFocus && !isEditableElement(event.target)) { return; } event.preventDefault(); event.stopPropagation(); void handleRun(); }; window.addEventListener('keydown', handleRunShortcut); return () => { window.removeEventListener('keydown', handleRunShortcut); }; }, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]); useEffect(() => { const handleRunActiveQuery = () => { if (activeTabId !== tab.id) { return; } void handleRun(); }; window.addEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); return () => { window.removeEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener); }; }, [activeTabId, tab.id, handleRun]); const handleSave = async () => { try { const values = await saveForm.validateFields(); saveQuery({ id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`, name: values.name, sql: getCurrentQuery(), connectionId: currentConnectionId, dbName: currentDb || tab.dbName || '', createdAt: Date.now() }); message.success('查询已保存!'); setIsSaveModalOpen(false); } catch (e) { } }; const handleCloseResult = (key: string) => { setResultSets(prev => { const idx = prev.findIndex(r => r.key === key); if (idx < 0) return prev; const next = prev.filter(r => r.key !== key); setActiveResultKey(prevActive => { if (prevActive && prevActive !== key) return prevActive; const nextKey = next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; return nextKey; }); return next; }); }; return (
({ label: db, value: db }))} showSearch />
); }; export default QueryEditor;