Files
MyGoNavi/frontend/src/components/QueryEditor.tsx
Syngnat 7612657ded 💄 style(query-editor): 调整 v2 查询工具栏布局样式
- 为查询工具栏控件增加 v2 专用 class,移除 v2 下 inline 固定宽度依赖

- 使用内容宽度约束选择区,避免最大行数后出现多余空白

- 覆盖 Ant Design Button.Group 负 margin 和伪元素合并效果

- 增加 CSS 静态断言覆盖对齐、间距和响应式布局
2026-06-02 11:54:06 +08:00

5178 lines
227 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import Editor, { type OnMount } from './MonacoEditor';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts";
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable';
import { quoteIdentPart } from '../utils/sql';
import { resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection';
import { isMacLikePlatform } from '../utils/appearance';
import { splitSidebarQualifiedName } from '../utils/sidebarLocate';
import { normalizeSidebarViewName } from '../utils/sidebarMetadata';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
import { getQueryTabDraft, hasQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
import {
getColumnDefinitionKey,
getColumnDefinitionName,
} from '../utils/columnDefinition';
const SQL_KEYWORDS = [
'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',
];
// SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源)
const SQL_FUNCTIONS: { name: string; detail: string }[] = [
// 聚合函数
{ name: 'COUNT', detail: '聚合 - 计数' },
{ name: 'SUM', detail: '聚合 - 求和' },
{ name: 'AVG', detail: '聚合 - 平均值' },
{ name: 'MAX', detail: '聚合 - 最大值' },
{ name: 'MIN', detail: '聚合 - 最小值' },
{ name: 'GROUP_CONCAT', detail: '聚合 - 拼接分组值' },
// 字符串函数
{ name: 'CONCAT', detail: '字符串 - 拼接' },
{ name: 'CONCAT_WS', detail: '字符串 - 带分隔符拼接' },
{ name: 'SUBSTRING', detail: '字符串 - 截取子串' },
{ name: 'SUBSTR', detail: '字符串 - 截取子串' },
{ name: 'LEFT', detail: '字符串 - 从左截取' },
{ name: 'RIGHT', detail: '字符串 - 从右截取' },
{ name: 'LENGTH', detail: '字符串 - 字节长度' },
{ name: 'CHAR_LENGTH', detail: '字符串 - 字符长度' },
{ name: 'UPPER', detail: '字符串 - 转大写' },
{ name: 'LOWER', detail: '字符串 - 转小写' },
{ name: 'TRIM', detail: '字符串 - 去空格' },
{ name: 'LTRIM', detail: '字符串 - 去左空格' },
{ name: 'RTRIM', detail: '字符串 - 去右空格' },
{ name: 'REPLACE', detail: '字符串 - 替换' },
{ name: 'REVERSE', detail: '字符串 - 反转' },
{ name: 'REPEAT', detail: '字符串 - 重复' },
{ name: 'LPAD', detail: '字符串 - 左填充' },
{ name: 'RPAD', detail: '字符串 - 右填充' },
{ name: 'INSTR', detail: '字符串 - 查找位置' },
{ name: 'LOCATE', detail: '字符串 - 查找位置' },
{ name: 'FIND_IN_SET', detail: '字符串 - 在集合中查找' },
{ name: 'FORMAT', detail: '字符串 - 数字格式化' },
{ name: 'SPACE', detail: '字符串 - 生成空格' },
{ name: 'INSERT', detail: '字符串 - 插入替换' },
{ name: 'FIELD', detail: '字符串 - 返回位置索引' },
{ name: 'ELT', detail: '字符串 - 按索引返回' },
{ name: 'HEX', detail: '字符串 - 十六进制编码' },
{ name: 'UNHEX', detail: '字符串 - 十六进制解码' },
// 数学函数
{ name: 'ABS', detail: '数学 - 绝对值' },
{ name: 'CEIL', detail: '数学 - 向上取整' },
{ name: 'CEILING', detail: '数学 - 向上取整' },
{ name: 'FLOOR', detail: '数学 - 向下取整' },
{ name: 'ROUND', detail: '数学 - 四舍五入' },
{ name: 'TRUNCATE', detail: '数学 - 截断小数' },
{ name: 'MOD', detail: '数学 - 取模' },
{ name: 'RAND', detail: '数学 - 随机数' },
{ name: 'SIGN', detail: '数学 - 符号' },
{ name: 'POWER', detail: '数学 - 幂运算' },
{ name: 'POW', detail: '数学 - 幂运算' },
{ name: 'SQRT', detail: '数学 - 平方根' },
{ name: 'LOG', detail: '数学 - 对数' },
{ name: 'LOG2', detail: '数学 - 以2为底对数' },
{ name: 'LOG10', detail: '数学 - 以10为底对数' },
{ name: 'LN', detail: '数学 - 自然对数' },
{ name: 'EXP', detail: '数学 - e的次方' },
{ name: 'PI', detail: '数学 - 圆周率' },
{ name: 'GREATEST', detail: '数学 - 返回最大值' },
{ name: 'LEAST', detail: '数学 - 返回最小值' },
// 日期时间函数
{ name: 'NOW', detail: '日期 - 当前日期时间' },
{ name: 'CURDATE', detail: '日期 - 当前日期' },
{ name: 'CURRENT_DATE', detail: '日期 - 当前日期' },
{ name: 'CURTIME', detail: '日期 - 当前时间' },
{ name: 'CURRENT_TIME', detail: '日期 - 当前时间' },
{ name: 'CURRENT_TIMESTAMP', detail: '日期 - 当前时间戳' },
{ name: 'SYSDATE', detail: '日期 - 系统当前时间' },
{ name: 'DATE', detail: '日期 - 提取日期部分' },
{ name: 'TIME', detail: '日期 - 提取时间部分' },
{ name: 'YEAR', detail: '日期 - 提取年份' },
{ name: 'MONTH', detail: '日期 - 提取月份' },
{ name: 'DAY', detail: '日期 - 提取天' },
{ name: 'DAYOFWEEK', detail: '日期 - 星期几(1=周日)' },
{ name: 'DAYOFYEAR', detail: '日期 - 年中第几天' },
{ name: 'HOUR', detail: '日期 - 提取小时' },
{ name: 'MINUTE', detail: '日期 - 提取分钟' },
{ name: 'SECOND', detail: '日期 - 提取秒' },
{ name: 'DATE_FORMAT', detail: '日期 - 格式化' },
{ name: 'DATE_ADD', detail: '日期 - 加日期' },
{ name: 'DATE_SUB', detail: '日期 - 减日期' },
{ name: 'DATEDIFF', detail: '日期 - 日期差(天)' },
{ name: 'TIMEDIFF', detail: '日期 - 时间差' },
{ name: 'TIMESTAMPDIFF', detail: '日期 - 时间戳差' },
{ name: 'TIMESTAMPADD', detail: '日期 - 时间戳加' },
{ name: 'STR_TO_DATE', detail: '日期 - 字符串转日期' },
{ name: 'UNIX_TIMESTAMP', detail: '日期 - Unix时间戳' },
{ name: 'FROM_UNIXTIME', detail: '日期 - 从Unix时间戳转换' },
{ name: 'LAST_DAY', detail: '日期 - 月末日期' },
{ name: 'WEEK', detail: '日期 - 第几周' },
{ name: 'QUARTER', detail: '日期 - 第几季度' },
{ name: 'ADDDATE', detail: '日期 - 加日期' },
{ name: 'SUBDATE', detail: '日期 - 减日期' },
// 条件/流程控制函数
{ name: 'IF', detail: '条件 - 如果' },
{ name: 'IFNULL', detail: '条件 - NULL替换' },
{ name: 'NULLIF', detail: '条件 - 相等返回NULL' },
{ name: 'COALESCE', detail: '条件 - 返回第一个非NULL' },
{ name: 'CASE', detail: '条件 - 分支表达式' },
// 类型转换
{ name: 'CAST', detail: '转换 - 类型转换' },
{ name: 'CONVERT', detail: '转换 - 类型/字符集转换' },
// JSON 函数
{ name: 'JSON_EXTRACT', detail: 'JSON - 提取值' },
{ name: 'JSON_UNQUOTE', detail: 'JSON - 去引号' },
{ name: 'JSON_SET', detail: 'JSON - 设置值' },
{ name: 'JSON_INSERT', detail: 'JSON - 插入值' },
{ name: 'JSON_REPLACE', detail: 'JSON - 替换值' },
{ name: 'JSON_REMOVE', detail: 'JSON - 删除值' },
{ name: 'JSON_CONTAINS', detail: 'JSON - 包含判断' },
{ name: 'JSON_OBJECT', detail: 'JSON - 构建对象' },
{ name: 'JSON_ARRAY', detail: 'JSON - 构建数组' },
{ name: 'JSON_LENGTH', detail: 'JSON - 元素个数' },
{ name: 'JSON_TYPE', detail: 'JSON - 值类型' },
{ name: 'JSON_VALID', detail: 'JSON - 验证' },
{ name: 'JSON_KEYS', detail: 'JSON - 获取键列表' },
// 加密/哈希函数
{ name: 'MD5', detail: '加密 - MD5哈希' },
{ name: 'SHA1', detail: '加密 - SHA1哈希' },
{ name: 'SHA2', detail: '加密 - SHA2哈希' },
{ name: 'UUID', detail: '工具 - 生成UUID' },
// 信息函数
{ name: 'DATABASE', detail: '信息 - 当前数据库' },
{ name: 'USER', detail: '信息 - 当前用户' },
{ name: 'VERSION', detail: '信息 - MySQL版本' },
{ name: 'CONNECTION_ID', detail: '信息 - 连接ID' },
{ name: 'LAST_INSERT_ID', detail: '信息 - 最后插入ID' },
{ name: 'ROW_COUNT', detail: '信息 - 影响行数' },
{ name: 'FOUND_ROWS', detail: '信息 - 匹配总行数' },
{ name: 'CHARSET', detail: '信息 - 字符集' },
{ name: 'COLLATION', detail: '信息 - 排序规则' },
// 窗口函数
{ name: 'ROW_NUMBER', detail: '窗口 - 行号' },
{ name: 'RANK', detail: '窗口 - 排名(有间隔)' },
{ name: 'DENSE_RANK', detail: '窗口 - 排名(无间隔)' },
{ name: 'NTILE', detail: '窗口 - 分桶' },
{ name: 'LAG', detail: '窗口 - 前一行' },
{ name: 'LEAD', detail: '窗口 - 后一行' },
{ name: 'FIRST_VALUE', detail: '窗口 - 第一个值' },
{ name: 'LAST_VALUE', detail: '窗口 - 最后一个值' },
{ name: 'NTH_VALUE', detail: '窗口 - 第N个值' },
// 其他
{ name: 'DISTINCT', detail: '修饰 - 去重' },
{ name: 'EXISTS', detail: '修饰 - 存在判断' },
{ name: 'BETWEEN', detail: '修饰 - 范围判断' },
{ name: 'LIKE', detail: '修饰 - 模式匹配' },
{ name: 'REGEXP', detail: '修饰 - 正则匹配' },
{ name: 'BENCHMARK', detail: '工具 - 性能测试' },
{ name: 'SLEEP', detail: '工具 - 延时' },
];
// HMR 重载时释放旧注册避免补全项重复
const _g = globalThis as any;
const SQL_COMPLETION_PROVIDER_VERSION = '20260602-table-fuzzy-lazy-v2';
if (!_g.__gonaviSqlCompletionState) {
_g.__gonaviSqlCompletionState = { registered: false, version: '', disposables: [] as any[] };
}
if (!Array.isArray(_g.__gonaviSqlCompletionState.disposables)) {
_g.__gonaviSqlCompletionState.disposables = [];
}
let sqlCompletionRegistered = _g.__gonaviSqlCompletionState.registered;
let sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
// 模块级共享变量completion provider 从这些变量读取当前活跃 Tab 的状态。
// 每个 QueryEditor 实例在成为活跃 Tab 时更新这些变量,确保 provider 始终使用正确的上下文。
let sharedCurrentDb = '';
let sharedCurrentConnectionId = '';
let sharedConnections: any[] = [];
type CompletionTableMeta = {dbName: string, tableName: string, comment?: string};
type CompletionColumnMeta = {dbName: string, tableName: string, name: string, type: string, comment?: string};
type CompletionViewMeta = {dbName: string, viewName: string, schemaName?: string};
type CompletionTriggerMeta = {dbName: string, triggerName: string, tableName: string, schemaName?: string};
type CompletionRoutineMeta = {dbName: string, routineName: string, routineType: string, schemaName?: string};
let sharedTablesData: CompletionTableMeta[] = [];
let sharedAllColumnsData: CompletionColumnMeta[] = [];
let sharedVisibleDbs: string[] = [];
let sharedColumnsCacheData: Record<string, any[]> = {};
const sharedLazyTablesCache: Record<string, CompletionTableMeta[] | undefined> = {};
const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> | undefined> = {};
const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_';
const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
type SimpleSelectInfo = {
selectsAll: boolean;
selectsBareAll: boolean;
writableColumns: Record<string, string>;
};
type QueryStatementPlan = {
originalSql: string;
executedSql: string;
tableRef?: QueryResultTableRef;
pkColumns: string[];
editLocator?: EditRowLocator;
warning?: string;
};
const stripQueryIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).trim();
}
return text;
};
const splitTopLevelComma = (text: string): string[] => {
const parts: string[] = [];
let current = '';
let parenDepth = 0;
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
for (let index = 0; index < text.length; index++) {
const ch = text[index];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if ((inSingle || inDouble) && ch === '\\') {
current += ch;
escaped = true;
continue;
}
if (!inDouble && !inBacktick && ch === "'") {
inSingle = !inSingle;
current += ch;
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
current += ch;
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
current += ch;
continue;
}
if (!inSingle && !inDouble && !inBacktick) {
if (ch === '(') parenDepth++;
if (ch === ')' && parenDepth > 0) parenDepth--;
if (ch === ',' && parenDepth === 0) {
parts.push(current.trim());
current = '';
continue;
}
}
current += ch;
}
if (current.trim()) parts.push(current.trim());
return parts;
};
const SIMPLE_IDENTIFIER_PATH_RE = /^(?:[`"\[]?[A-Za-z_][\w$]*[`"\]]?\s*\.\s*){0,2}[`"\[]?[A-Za-z_][\w$]*[`"\]]?$/;
const QUERY_ALIAS_RESERVED = new Set([
'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'join', 'left', 'right', 'inner', 'outer', 'on', 'union',
'for', 'connect', 'start', 'window', 'sample', 'pivot', 'unpivot', 'qualify', 'model',
]);
const getLastIdentifierPart = (path: string): string => {
const parts = String(path || '').split('.').map((part) => stripQueryIdentifierQuotes(part.trim())).filter(Boolean);
return parts[parts.length - 1] || '';
};
const resolveSimpleSelectItemColumn = (item: string): { resultName: string; sourceName: string } | 'all' | undefined => {
const text = String(item || '').trim();
if (!text) return undefined;
if (text === '*' || /\.\s*\*$/.test(text)) return 'all';
let expr = text;
let alias = '';
const asMatch = text.match(/^(.*?)\s+AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/i);
if (asMatch) {
expr = asMatch[1].trim();
alias = stripQueryIdentifierQuotes(asMatch[2]);
} else {
const bareAliasMatch = text.match(/^(.*?)\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/);
if (bareAliasMatch && SIMPLE_IDENTIFIER_PATH_RE.test(bareAliasMatch[1].trim())) {
const candidateAlias = stripQueryIdentifierQuotes(bareAliasMatch[2]);
if (candidateAlias && !QUERY_ALIAS_RESERVED.has(candidateAlias.toLowerCase())) {
expr = bareAliasMatch[1].trim();
alias = candidateAlias;
}
}
}
if (!SIMPLE_IDENTIFIER_PATH_RE.test(expr)) return undefined;
const sourceName = getLastIdentifierPart(expr);
const resultName = alias || sourceName;
return sourceName && resultName ? { resultName, sourceName } : undefined;
};
const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => {
const match = String(sql || '').match(/^\s*SELECT\s+([\s\S]+?)\s+FROM\s+/i);
if (!match) return undefined;
const selectList = match[1].trim();
if (!selectList || /^DISTINCT\b/i.test(selectList)) return undefined;
const writableColumns: Record<string, string> = {};
let selectsAll = false;
let selectsBareAll = false;
for (const item of splitTopLevelComma(selectList)) {
const trimmedItem = String(item || '').trim();
const resolved = resolveSimpleSelectItemColumn(item);
if (!resolved) continue;
if (resolved === 'all') {
selectsAll = true;
if (trimmedItem === '*') {
selectsBareAll = true;
}
continue;
}
writableColumns[resolved.resultName] = resolved.sourceName;
}
return { selectsAll, selectsBareAll, writableColumns };
};
const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => {
if (expressions.length === 0) return sql;
return String(sql || '').replace(
/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+[\s\S]*)$/i,
(_match, prefix, selectList, rest) => `${prefix}${String(selectList).trimEnd()}, ${expressions.join(', ')}${rest}`,
);
};
const QUERY_LOCATOR_SOURCE_ALIAS = 'gonavi_query_source';
const rewriteOracleSelectAllWithExpressions = (sql: string, expressions: string[]): string | undefined => {
if (expressions.length === 0) return undefined;
const match = String(sql || '').match(/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+)([\s\S]*)$/i);
if (!match) return undefined;
const prefix = match[1];
const selectList = match[2].trim();
const fromKeyword = match[3];
const fromTail = match[4];
const selectItems = splitTopLevelComma(selectList);
if (selectItems.length === 0) return undefined;
let selectAllFound = false;
for (const item of selectItems) {
if (String(item || '').trim() === '*') {
selectAllFound = true;
break;
}
}
if (!selectAllFound) return undefined;
const fromTrimmed = fromTail.trimStart();
const tableMatch = fromTrimmed.match(/^((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})([\s\S]*)$/);
if (!tableMatch) return undefined;
const tableText = tableMatch[1];
const afterTable = tableMatch[2] || '';
const parseAlias = (tail: string): { alias: string; remainder: string } => {
const trimmedTail = String(tail || '').trimStart();
if (!trimmedTail) {
return { alias: '', remainder: tail };
}
const asMatch = trimmedTail.match(/^AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/i);
if (asMatch) {
const candidate = stripQueryIdentifierQuotes(asMatch[1]);
if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) {
return { alias: candidate, remainder: asMatch[2] || '' };
}
}
const bareMatch = trimmedTail.match(/^([`"\[]?[A-Za-z_][\w$]*[`"\]]?)([\s\S]*)$/);
if (bareMatch) {
const candidate = stripQueryIdentifierQuotes(bareMatch[1]);
if (candidate && !QUERY_ALIAS_RESERVED.has(candidate.toLowerCase())) {
return { alias: candidate, remainder: bareMatch[2] || '' };
}
}
return { alias: '', remainder: tail };
};
const parsedAlias = parseAlias(afterTable);
const sourceAlias = parsedAlias.alias || QUERY_LOCATOR_SOURCE_ALIAS;
const qualifiedExpressions = expressions
.map((expression) => {
const trimmed = String(expression || '').trim();
if (!trimmed) return '';
if (/^ROWID\b/i.test(trimmed)) {
return trimmed.replace(/^(\s*)ROWID\b/i, `$1${sourceAlias}.ROWID`);
}
return trimmed;
})
.filter(Boolean);
if (qualifiedExpressions.length === 0) return undefined;
const rewrittenSelectItems = selectItems.map((item) => {
const trimmed = String(item || '').trim();
if (trimmed === '*') {
return `${sourceAlias}.*`;
}
return item.trimEnd();
});
const aliasClause = parsedAlias.alias ? ` ${parsedAlias.alias}` : ` ${sourceAlias}`;
const finalSelectItems = [...rewrittenSelectItems, ...qualifiedExpressions];
return `${prefix}${finalSelectItems.join(', ')}${fromKeyword}${tableText}${aliasClause}${parsedAlias.remainder}`;
};
const findWritableResultColumnForSource = (writableColumns: Record<string, string>, target: string): string | undefined => {
const normalizedTarget = String(target || '').trim().toLowerCase();
return Object.entries(writableColumns || {}).find(([, sourceColumn]) => (
String(sourceColumn || '').trim().toLowerCase() === normalizedTarget
))?.[0];
};
const resolveMetadataColumnName = (tableColumnNames: string[], sourceColumn: string): string => {
const normalizedSource = String(sourceColumn || '').trim();
if (!normalizedSource) return '';
return tableColumnNames.find((column) => String(column || '').trim().toLowerCase() === normalizedSource.toLowerCase())
|| normalizedSource;
};
const buildQueryLocatorAlias = (column: string, index: number): string => {
const normalized = String(column || '').trim().replace(/[^A-Za-z0-9_]/g, '_').slice(0, 48) || 'column';
return `${QUERY_LOCATOR_ALIAS_PREFIX}${index}_${normalized}`;
};
const buildQueryLocatorColumnExpression = (dbType: string, column: string, alias: string): string => (
`${quoteIdentPart(dbType, column)} AS ${quoteIdentPart(dbType, alias)}`
);
const buildQueryRowIDExpression = (dbType: string, sourceAlias?: string): string => (
`${sourceAlias ? `${sourceAlias}.` : ''}ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
);
const escapeMetadataSqlLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const quoteSqlServerDbIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
type MetadataQuerySpec = {
sql: string;
inferredType?: 'FUNCTION' | 'PROCEDURE';
};
type MetadataQueryResult = {
rows: Record<string, any>[];
inferredType?: 'FUNCTION' | 'PROCEDURE';
};
const normalizeMetadataDialect = (conn: any): string => {
const type = String(conn?.config?.type || '').trim().toLowerCase();
const driver = String(conn?.config?.driver || '').trim();
const dialect = resolveSqlDialect(type, driver, {
oceanBaseProtocol: conn?.config?.oceanBaseProtocol,
});
if (dialect === 'diros' || dialect === 'sphinx' || dialect === 'mariadb' || dialect === 'oceanbase') return 'mysql';
if (dialect === 'dameng') return 'oracle';
return String(dialect || '').toLowerCase();
};
const buildCompletionTableCommentSQL = (dialect: string, dbName: string): string => {
const db = String(dbName || '').trim();
const escapedDb = escapeMetadataSqlLiteral(db);
switch (dialect) {
case 'mysql':
case 'starrocks':
return `SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment FROM information_schema.tables WHERE table_schema = '${escapedDb}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
case 'postgres':
case 'kingbase':
case 'vastbase':
case 'highgo':
case 'opengauss':
return `SELECT n.nspname || '.' || c.relname AS table_name, obj_description(c.oid, 'pg_class') AS table_comment FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, c.relname`;
case 'sqlserver': {
const safeDb = quoteSqlServerDbIdentifier(db);
return `SELECT s.name + '.' + t.name AS table_name, ep.value AS table_comment FROM ${safeDb}.sys.tables t JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN ${safeDb}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' WHERE t.type = 'U' ORDER BY s.name, t.name`;
}
case 'clickhouse':
return `SELECT name AS table_name, comment AS table_comment FROM system.tables WHERE database = '${escapedDb}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
case 'oracle': {
const owner = escapedDb.toUpperCase();
return `SELECT table_name, comments AS table_comment FROM all_tab_comments WHERE owner = '${owner}' ORDER BY table_name`;
}
default:
return '';
}
};
const getCaseInsensitiveValue = (row: Record<string, any>, keys: string[]): any => {
for (const key of keys) {
for (const rowKey of Object.keys(row || {})) {
if (rowKey.toLowerCase() === key.toLowerCase()) {
return row[rowKey];
}
}
}
return undefined;
};
const normalizeCommentText = (value: unknown): string => {
if (value === null || value === undefined) return '';
const text = String(value).trim();
if (!text || text.toLowerCase() === '<nil>') return '';
return text;
};
const buildCompletionDocumentation = (comment?: string): string | undefined => {
const text = normalizeCommentText(comment);
return text ? `备注:${text}` : undefined;
};
const appendCommentToDetail = (detail: string, comment?: string): string => {
const text = normalizeCommentText(comment);
return text ? `${detail} - ${text}` : detail;
};
const stripCompletionIdentifierQuotes = (ident: string): string => {
let raw = String(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 normalizeCompletionQualifiedName = (ident: string): string => {
const raw = String(ident || '').trim();
if (!raw) return raw;
return raw
.split('.')
.map(p => stripCompletionIdentifierQuotes(p.trim()))
.filter(Boolean)
.join('.');
};
const getCompletionQualifiedNameLastPart = (qualified: string): string => {
const raw = normalizeCompletionQualifiedName(qualified);
if (!raw) return raw;
const parts = raw.split('.').filter(Boolean);
return parts[parts.length - 1] || raw;
};
const splitCompletionSchemaAndTable = (qualified: string): { schema: string; table: string } => {
const raw = normalizeCompletionQualifiedName(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 DEFAULT_QUERY_TEMPLATE = 'SELECT * FROM ';
const getTabQueryValue = (tab: TabData): string => (
typeof tab.query === 'string' ? tab.query : ''
);
const getInitialEditorQuery = (tab: TabData): string => {
if (hasQueryTabDraft(tab.id)) {
return getQueryTabDraft(tab.id);
}
const tabQuery = getTabQueryValue(tab);
if (tabQuery || tab.filePath || tab.savedQueryId || tab.readOnly) {
return tabQuery;
}
return DEFAULT_QUERY_TEMPLATE;
};
const resolveNextResultSetIndex = (sets: Array<{ key?: string }>): number => {
const maxIndex = sets.reduce((max, item) => {
const match = String(item?.key || '').match(/^result-(\d+)$/);
const index = match ? Number(match[1]) : 0;
return Number.isFinite(index) ? Math.max(max, index) : max;
}, 0);
return maxIndex + 1;
};
const normalizeExecutedSqlKey = (sql: string): string => String(sql || '')
.replace(/\r\n/g, '\n')
.replace(//g, ';')
.trim()
.replace(/;+\s*$/g, '')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase();
const areSqlStatementListsEqual = (left: string[], right: string[]): boolean => (
left.length === right.length
&& left.every((statement, index) => normalizeExecutedSqlKey(statement) === normalizeExecutedSqlKey(right[index]))
);
const normalizeEditorPosition = (position: any): { lineNumber: number; column: number } | null => {
if (!position) return null;
const lineNumber = Number(position.positionLineNumber ?? position.lineNumber ?? position.endLineNumber ?? position.startLineNumber ?? position.selectionStartLineNumber);
const column = Number(position.positionColumn ?? position.column ?? position.endColumn ?? position.startColumn ?? position.selectionStartColumn);
if (!Number.isFinite(lineNumber) || !Number.isFinite(column) || lineNumber < 1 || column < 1) {
return null;
}
return { lineNumber, column };
};
const getNormalizedOffsetAtPosition = (
sqlText: string,
position: { lineNumber: number; column: number },
): number => {
const text = String(sqlText || '').replace(/\r\n/g, '\n');
const lines = text.split('\n');
const targetLineIndex = Math.max(0, Math.min(lines.length - 1, position.lineNumber - 1));
let offset = 0;
for (let index = 0; index < targetLineIndex; index++) {
offset += (lines[index]?.length || 0) + 1;
}
return Math.max(0, Math.min(text.length, offset + Math.max(0, position.column - 1)));
};
const getFirstRowValue = (row: Record<string, any>): string => {
for (const value of Object.values(row || {})) {
if (value !== undefined && value !== null) {
const normalized = String(value).trim();
if (normalized !== '') return normalized;
}
}
return '';
};
const normalizeMetadataQuerySpecs = (specs: MetadataQuerySpec[]): MetadataQuerySpec[] => {
const seen = new Set<string>();
const normalized: MetadataQuerySpec[] = [];
specs.forEach((spec) => {
const sql = String(spec.sql || '').trim();
if (!sql) return;
const key = `${spec.inferredType || ''}@@${sql}`;
if (seen.has(key)) return;
seen.add(key);
normalized.push({ sql, inferredType: spec.inferredType });
});
return normalized;
};
const buildQualifiedCompletionName = (schemaName: string, objectName: string): string => {
const schema = String(schemaName || '').trim();
const object = String(objectName || '').trim();
if (!object) return '';
if (!schema || object.includes('.')) return object;
return `${schema}.${object}`;
};
const buildCompletionViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
const safeDbName = escapeMetadataSqlLiteral(dbName);
switch (dialect) {
case 'mysql':
case 'starrocks': {
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
return normalizeMetadataQuerySpecs([
{
sql: safeDbName
? `SELECT TABLE_NAME AS view_name, TABLE_SCHEMA AS schema_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME`
: '',
},
{ sql: dbIdent ? `SHOW FULL TABLES FROM \`${dbIdent}\`` : '' },
{ sql: 'SHOW FULL TABLES' },
]);
}
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return [{ sql: `SELECT schemaname AS schema_name, viewname AS view_name FROM pg_catalog.pg_views WHERE schemaname != 'information_schema' AND schemaname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY schemaname, viewname` }];
case 'sqlserver': {
const safeDb = quoteSqlServerDbIdentifier(dbName || 'master');
return [{ sql: `SELECT s.name AS schema_name, v.name AS view_name FROM ${safeDb}.sys.views v JOIN ${safeDb}.sys.schemas s ON v.schema_id = s.schema_id ORDER BY s.name, v.name` }];
}
case 'oracle': {
return normalizeMetadataQuerySpecs([
{ sql: 'SELECT VIEW_NAME AS view_name FROM USER_VIEWS ORDER BY VIEW_NAME' },
{ sql: 'SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = USER ORDER BY VIEW_NAME' },
{
sql: safeDbName
? `SELECT OWNER AS schema_name, VIEW_NAME AS view_name FROM ALL_VIEWS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY VIEW_NAME`
: '',
},
]);
}
case 'sqlite':
return [{ sql: 'SELECT name AS view_name FROM sqlite_master WHERE type = \'view\' ORDER BY name' }];
case 'duckdb':
return [{ sql: `SELECT table_schema AS schema_name, table_name AS view_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name` }];
default:
return [];
}
};
const buildCompletionMaterializedViewsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
if (dialect !== 'starrocks') {
return [];
}
const safeDbName = escapeMetadataSqlLiteral(dbName);
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
return normalizeMetadataQuerySpecs([
{
sql: safeDbName
? `SELECT TABLE_SCHEMA AS schema_name, TABLE_NAME AS object_name FROM information_schema.tables WHERE TABLE_SCHEMA = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%MATERIALIZED%' ORDER BY TABLE_NAME`
: '',
},
{ sql: dbIdent ? `SHOW MATERIALIZED VIEWS FROM \`${dbIdent}\`` : '' },
{ sql: 'SHOW MATERIALIZED VIEWS' },
]);
};
const buildCompletionTriggersMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
const safeDbName = escapeMetadataSqlLiteral(dbName);
switch (dialect) {
case 'mysql':
case 'starrocks': {
const dbIdent = String(dbName || '').replace(/`/g, '``').trim();
return normalizeMetadataQuerySpecs([
{
sql: safeDbName
? `SELECT TRIGGER_NAME AS trigger_name, EVENT_OBJECT_TABLE AS table_name, TRIGGER_SCHEMA AS schema_name FROM information_schema.triggers WHERE trigger_schema = '${safeDbName}' ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME`
: '',
},
{ sql: dbIdent ? `SHOW TRIGGERS FROM \`${dbIdent}\`` : '' },
{ sql: 'SHOW TRIGGERS' },
]);
}
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return [{ sql: `SELECT DISTINCT event_object_schema AS schema_name, event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema NOT IN ('pg_catalog', 'information_schema') AND trigger_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY event_object_schema, event_object_table, trigger_name` }];
case 'sqlserver': {
const safeDb = quoteSqlServerDbIdentifier(dbName || 'master');
return [{ sql: `SELECT s.name AS schema_name, t.name AS table_name, tr.name AS trigger_name FROM ${safeDb}.sys.triggers tr JOIN ${safeDb}.sys.tables t ON tr.parent_id = t.object_id JOIN ${safeDb}.sys.schemas s ON t.schema_id = s.schema_id WHERE tr.parent_class = 1 ORDER BY s.name, t.name, tr.name` }];
}
case 'oracle':
if (!safeDbName) {
return [{ sql: 'SELECT TRIGGER_NAME AS trigger_name, TABLE_NAME AS table_name FROM USER_TRIGGERS ORDER BY TABLE_NAME, TRIGGER_NAME' }];
}
return [{ sql: `SELECT OWNER AS schema_name, TABLE_NAME AS table_name, TRIGGER_NAME AS trigger_name FROM ALL_TRIGGERS WHERE OWNER = '${safeDbName.toUpperCase()}' ORDER BY TABLE_NAME, TRIGGER_NAME` }];
case 'sqlite':
return [{ sql: 'SELECT name AS trigger_name, tbl_name AS table_name FROM sqlite_master WHERE type = \'trigger\' ORDER BY tbl_name, name' }];
default:
return [];
}
};
const buildCompletionFunctionsMetadataQuerySpecs = (dialect: string, dbName: string): MetadataQuerySpec[] => {
const safeDbName = escapeMetadataSqlLiteral(dbName);
switch (dialect) {
case 'mysql':
case 'starrocks':
return normalizeMetadataQuerySpecs([
{
sql: safeDbName
? `SELECT ROUTINE_NAME AS routine_name, ROUTINE_TYPE AS routine_type, ROUTINE_SCHEMA AS schema_name FROM information_schema.routines WHERE routine_schema = '${safeDbName}' ORDER BY ROUTINE_TYPE, ROUTINE_NAME`
: '',
},
{
sql: safeDbName ? `SHOW FUNCTION STATUS WHERE Db = '${safeDbName}'` : 'SHOW FUNCTION STATUS',
inferredType: 'FUNCTION',
},
{
sql: safeDbName ? `SHOW PROCEDURE STATUS WHERE Db = '${safeDbName}'` : 'SHOW PROCEDURE STATUS',
inferredType: 'PROCEDURE',
},
]);
case 'postgres':
case 'kingbase':
case 'highgo':
case 'vastbase':
case 'opengauss':
return normalizeMetadataQuerySpecs([
{
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, CASE WHEN p.prokind = 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, routine_type, p.proname`,
},
{
sql: `SELECT r.routine_schema AS schema_name, r.routine_name AS routine_name, COALESCE(NULLIF(UPPER(r.routine_type), ''), 'FUNCTION') AS routine_type FROM information_schema.routines r WHERE r.routine_schema NOT IN ('pg_catalog', 'information_schema') AND r.routine_schema NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY r.routine_schema, routine_type, r.routine_name`,
},
{
sql: `SELECT n.nspname AS schema_name, p.proname AS routine_name, 'FUNCTION' AS routine_type FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg|_%' ESCAPE '|' ORDER BY n.nspname, p.proname`,
},
]);
case 'sqlserver': {
const safeDb = quoteSqlServerDbIdentifier(dbName || 'master');
return [{ sql: `SELECT s.name AS schema_name, o.name AS routine_name, CASE o.type WHEN 'P' THEN 'PROCEDURE' WHEN 'FN' THEN 'FUNCTION' WHEN 'IF' THEN 'FUNCTION' WHEN 'TF' THEN 'FUNCTION' END AS routine_type FROM ${safeDb}.sys.objects o JOIN ${safeDb}.sys.schemas s ON o.schema_id = s.schema_id WHERE o.type IN ('P','FN','IF','TF') ORDER BY o.type, s.name, o.name` }];
}
case 'oracle':
return normalizeMetadataQuerySpecs([
{ sql: `SELECT OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` },
{ sql: `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = USER AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME` },
{
sql: safeDbName
? `SELECT OWNER AS schema_name, OBJECT_NAME AS routine_name, OBJECT_TYPE AS routine_type FROM ALL_OBJECTS WHERE OWNER = '${safeDbName.toUpperCase()}' AND OBJECT_TYPE IN ('FUNCTION','PROCEDURE') ORDER BY OBJECT_TYPE, OBJECT_NAME`
: '',
},
]);
case 'duckdb':
return [{
sql: `SELECT schema_name, function_name AS routine_name, 'FUNCTION' AS routine_type FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND COALESCE(macro_definition, '') <> '' ORDER BY schema_name, function_name`,
inferredType: 'FUNCTION',
}];
default:
return [];
}
};
const queryCompletionMetadataRowsBySpecs = async (
config: Record<string, any>,
dbName: string,
specs: MetadataQuerySpec[],
): Promise<MetadataQueryResult[]> => {
const normalizedSpecs = normalizeMetadataQuerySpecs(specs);
if (normalizedSpecs.length === 0) {
return [];
}
const results: MetadataQueryResult[] = [];
for (const spec of normalizedSpecs) {
try {
const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, spec.sql);
if (!result.success || !Array.isArray(result.data)) {
continue;
}
results.push({
rows: result.data as Record<string, any>[],
inferredType: spec.inferredType,
});
} catch {
// 忽略单条元数据查询失败,继续走兼容查询。
}
}
return results;
};
type QueryEditorNavigationTarget =
| { type: 'database'; dbName: string }
| { type: 'table'; dbName: string; tableName: string; schemaName?: string }
| { type: 'view'; dbName: string; viewName: string; schemaName?: string }
| { type: 'materialized-view'; dbName: string; viewName: string; schemaName?: string }
| { type: 'trigger'; dbName: string; triggerName: string; tableName: string; schemaName?: string }
| { type: 'routine'; dbName: string; routineName: string; routineType: string; schemaName?: string };
type QueryEditorHoverTarget =
| { kind: 'database'; dbName: string; range: { startColumn: number; endColumn: number } }
| { kind: 'table'; dbName: string; tableName: string; schemaName?: string; comment?: string; range: { startColumn: number; endColumn: number } }
| { kind: 'view'; dbName: string; viewName: string; schemaName?: string; range: { startColumn: number; endColumn: number } }
| { kind: 'materialized-view'; dbName: string; viewName: string; schemaName?: string; range: { startColumn: number; endColumn: number } }
| { kind: 'trigger'; dbName: string; triggerName: string; tableName: string; schemaName?: string; range: { startColumn: number; endColumn: number } }
| { kind: 'routine'; dbName: string; routineName: string; routineType: string; schemaName?: string; range: { startColumn: number; endColumn: number } }
| { kind: 'column'; dbName: string; tableName: string; columnName: string; type?: string; comment?: string; schemaName?: string; range: { startColumn: number; endColumn: number } };
const QUERY_EDITOR_IDENTIFIER_CHAR_REGEX = /[A-Za-z0-9_$`"\[\].]/;
const QUERY_EDITOR_HOVER_DELAY_MS = 1000;
const QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH = 200_000;
const QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS = 800;
const QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH = 50_000;
const QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH = 50_000;
const getQueryEditorModelValueLength = (model: any): number | null => {
if (!model || typeof model.getValueLength !== 'function') {
return null;
}
try {
const length = Number(model.getValueLength());
return Number.isFinite(length) ? length : null;
} catch {
return null;
}
};
const getQueryEditorModelTextIfWithinLimit = (model: any, maxTextLength: number): string | null => {
const modelLength = getQueryEditorModelValueLength(model);
if (modelLength !== null && modelLength > maxTextLength) {
return null;
}
const text = String(model?.getValue?.() || '');
return text.length <= maxTextLength ? text : null;
};
const getQueryEditorObjectResolveText = (
model: any,
lineContent: string,
maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH,
): string => getQueryEditorModelTextIfWithinLimit(model, maxTextLength) ?? lineContent;
const findIdentifierWindowAtOffset = (
lineContent: string,
rawOffset: number,
): { start: number; end: number } | null => {
const text = String(lineContent || '');
if (!text) return null;
const maxIndex = text.length - 1;
if (maxIndex < 0) return null;
let offset = Math.max(0, Math.min(maxIndex, Number.isFinite(rawOffset) ? rawOffset : 0));
if (!QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset] || '')) {
if (offset > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset - 1] || '')) {
offset -= 1;
} else if (offset < maxIndex && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[offset + 1] || '')) {
offset += 1;
} else {
return null;
}
}
let start = offset;
while (start > 0 && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[start - 1] || '')) {
start -= 1;
}
let end = offset + 1;
while (end < text.length && QUERY_EDITOR_IDENTIFIER_CHAR_REGEX.test(text[end] || '')) {
end += 1;
}
return start < end ? { start, end } : null;
};
const normalizeNavigationIdentifierParts = (text: string): string[] => (
String(text || '')
.split('.')
.map((part) => stripCompletionIdentifierQuotes(part))
.map((part) => part.trim())
.filter(Boolean)
);
const buildQueryEditorHoverMarkdown = (target: QueryEditorHoverTarget): string => {
const appendComment = (comment?: string): string => {
const normalized = normalizeCommentText(comment);
return normalized ? `\n\n${normalized}` : '';
};
switch (target.kind) {
case 'database':
return `**数据库**\n\n\`${target.dbName}\``;
case 'table':
return `**表** \`${target.tableName}\`\n\n库\`${target.dbName}\`${target.schemaName ? `\n\nSchema\`${target.schemaName}\`` : ''}${appendComment(target.comment)}`;
case 'view':
return `**视图** \`${target.viewName}\`\n\n库\`${target.dbName}\`${target.schemaName ? `\n\nSchema\`${target.schemaName}\`` : ''}`;
case 'materialized-view':
return `**物化视图** \`${target.viewName}\`\n\n库\`${target.dbName}\`${target.schemaName ? `\n\nSchema\`${target.schemaName}\`` : ''}`;
case 'trigger':
return `**触发器** \`${target.triggerName}\`\n\n库\`${target.dbName}\`\n\n表\`${target.tableName}\`${target.schemaName ? `\n\nSchema\`${target.schemaName}\`` : ''}`;
case 'routine':
return `**${target.routineType === 'PROCEDURE' ? '存储过程' : '函数'}** \`${target.routineName}\`\n\n库\`${target.dbName}\`${target.schemaName ? `\n\nSchema\`${target.schemaName}\`` : ''}`;
case 'column':
return `**字段** \`${target.columnName}\`${target.type ? `\n\n类型\`${target.type}\`` : ''}\n\n表\`${target.tableName}\`\n\n库\`${target.dbName}\`${target.schemaName ? `\n\nSchema\`${target.schemaName}\`` : ''}${appendComment(target.comment)}`;
default:
return '';
}
};
const buildQueryEditorAliasMap = (
fullText: string,
currentDb: string,
): Record<string, { dbName: string; tableName: string }> => {
const aliasMap: Record<string, { dbName: string; tableName: string }> = {};
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 aliasRegex = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+([`"]?\w+[`"]?(?:\s*\.\s*[`"]?\w+[`"]?)?)(?:\s+(?:AS\s+)?([`"]?\w+[`"]?))?/gi;
let match: RegExpExecArray | null;
while ((match = aliasRegex.exec(fullText)) !== null) {
const tableIdent = normalizeCompletionQualifiedName(match[1] || '');
if (!tableIdent) continue;
const parts = tableIdent.split('.');
let dbName = currentDb || '';
let tableName = tableIdent;
if (parts.length === 2) {
dbName = parts[0];
tableName = parts[1];
} else if (parts.length >= 3) {
dbName = parts[0];
tableName = parts.slice(1).join('.');
}
const shortTable = getCompletionQualifiedNameLastPart(tableIdent);
if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName };
const alias = stripCompletionIdentifierQuotes(match[2] || '').trim();
if (!alias) continue;
const loweredAlias = alias.toLowerCase();
if (reserved.has(loweredAlias)) continue;
aliasMap[loweredAlias] = { dbName, tableName };
}
return aliasMap;
};
export const resolveQueryEditorNavigationTarget = (
lineContent: string,
column: number,
currentDb: string,
visibleDbs: string[],
tables: CompletionTableMeta[],
views: CompletionViewMeta[] = [],
materializedViews: CompletionViewMeta[] = [],
triggers: CompletionTriggerMeta[] = [],
routines: CompletionRoutineMeta[] = [],
): QueryEditorNavigationTarget | null => {
const text = String(lineContent || '');
if (!text) return null;
const offset = Math.max(0, Number(column || 1) - 2);
const windowRange = findIdentifierWindowAtOffset(text, offset);
if (!windowRange) return null;
const rawIdentifier = text.slice(windowRange.start, windowRange.end).trim();
if (!rawIdentifier) return null;
const parts = normalizeNavigationIdentifierParts(rawIdentifier);
if (parts.length === 0 || parts.length > 3) return null;
const currentDbName = String(currentDb || '').trim();
const visibleDbSet = new Set(visibleDbs.map((db) => String(db || '').trim().toLowerCase()).filter(Boolean));
const tableMetas = tables.map((table) => {
const dbName = String(table.dbName || '').trim();
const rawTableName = String(table.tableName || '').trim();
const parsed = splitSidebarQualifiedName(rawTableName);
return {
dbName,
rawTableName,
normalizedDbName: dbName.toLowerCase(),
normalizedRawTableName: rawTableName.toLowerCase(),
normalizedObjectName: String(parsed.objectName || rawTableName).trim().toLowerCase(),
schemaName: String(parsed.schemaName || '').trim(),
normalizedSchemaName: String(parsed.schemaName || '').trim().toLowerCase(),
};
});
const buildObjectNameMeta = (
dbName: string,
rawObjectName: string,
explicitSchemaName = '',
) => {
const parsed = splitSidebarQualifiedName(rawObjectName);
const schemaName = String(explicitSchemaName || parsed.schemaName || '').trim();
const objectName = String(parsed.objectName || rawObjectName).trim();
return {
dbName: String(dbName || '').trim(),
rawObjectName: String(rawObjectName || '').trim(),
objectName,
schemaName,
normalizedDbName: String(dbName || '').trim().toLowerCase(),
normalizedRawObjectName: String(rawObjectName || '').trim().toLowerCase(),
normalizedObjectName: objectName.toLowerCase(),
normalizedSchemaName: schemaName.toLowerCase(),
};
};
const viewMetas = views.map((view) => buildObjectNameMeta(view.dbName, view.viewName, view.schemaName));
const materializedViewMetas = materializedViews.map((view) => buildObjectNameMeta(view.dbName, view.viewName, view.schemaName));
const triggerMetas = triggers.map((trigger) => ({
...buildObjectNameMeta(trigger.dbName, trigger.triggerName, trigger.schemaName),
tableName: String(trigger.tableName || '').trim(),
}));
const routineMetas = routines.map((routine) => ({
...buildObjectNameMeta(routine.dbName, routine.routineName, routine.schemaName),
routineType: String(routine.routineType || 'FUNCTION').trim().toUpperCase() || 'FUNCTION',
}));
const findTable = (candidateDbName: string, candidateTableName: string, schemaName = ''): QueryEditorNavigationTarget | null => {
const normalizedDbName = String(candidateDbName || '').trim().toLowerCase();
const normalizedTableName = String(candidateTableName || '').trim().toLowerCase();
const normalizedSchemaName = String(schemaName || '').trim().toLowerCase();
if (!normalizedDbName || !normalizedTableName) return null;
const exactQualifiedName = normalizedSchemaName ? `${normalizedSchemaName}.${normalizedTableName}` : normalizedTableName;
const exact = tableMetas.find((meta) =>
meta.normalizedDbName === normalizedDbName
&& meta.normalizedRawTableName === exactQualifiedName
);
if (exact) {
return {
type: 'table',
dbName: exact.dbName,
tableName: exact.rawTableName,
schemaName: exact.schemaName || undefined,
};
}
const matched = tableMetas.find((meta) =>
meta.normalizedDbName === normalizedDbName
&& meta.normalizedObjectName === normalizedTableName
&& (!normalizedSchemaName || meta.normalizedSchemaName === normalizedSchemaName)
);
if (!matched) return null;
return {
type: 'table',
dbName: matched.dbName,
tableName: matched.rawTableName,
schemaName: matched.schemaName || undefined,
};
};
const findNamedObject = <TMeta extends {
dbName: string;
rawObjectName: string;
objectName: string;
normalizedDbName: string;
normalizedRawObjectName: string;
normalizedObjectName: string;
normalizedSchemaName: string;
schemaName: string;
}>(
metas: TMeta[],
candidateDbName: string,
candidateObjectName: string,
schemaName = '',
): TMeta | null => {
const normalizedDbName = String(candidateDbName || '').trim().toLowerCase();
const normalizedObjectName = String(candidateObjectName || '').trim().toLowerCase();
const normalizedSchemaName = String(schemaName || '').trim().toLowerCase();
if (!normalizedDbName || !normalizedObjectName) return null;
const exactQualifiedName = normalizedSchemaName ? `${normalizedSchemaName}.${normalizedObjectName}` : normalizedObjectName;
const exact = metas.find((meta) =>
meta.normalizedDbName === normalizedDbName
&& meta.normalizedRawObjectName === exactQualifiedName
);
if (exact) {
return exact;
}
return metas.find((meta) =>
meta.normalizedDbName === normalizedDbName
&& meta.normalizedObjectName === normalizedObjectName
&& (!normalizedSchemaName || meta.normalizedSchemaName === normalizedSchemaName)
) || null;
};
const findView = (candidateDbName: string, candidateViewName: string, schemaName = ''): QueryEditorNavigationTarget | null => {
const matched = findNamedObject(viewMetas, candidateDbName, candidateViewName, schemaName);
if (!matched) return null;
return {
type: 'view',
dbName: matched.dbName,
viewName: matched.rawObjectName,
schemaName: matched.schemaName || undefined,
};
};
const findMaterializedView = (candidateDbName: string, candidateViewName: string, schemaName = ''): QueryEditorNavigationTarget | null => {
const matched = findNamedObject(materializedViewMetas, candidateDbName, candidateViewName, schemaName);
if (!matched) return null;
return {
type: 'materialized-view',
dbName: matched.dbName,
viewName: matched.rawObjectName,
schemaName: matched.schemaName || undefined,
};
};
const findTrigger = (candidateDbName: string, candidateTriggerName: string, schemaName = ''): QueryEditorNavigationTarget | null => {
const matched = findNamedObject(triggerMetas, candidateDbName, candidateTriggerName, schemaName);
if (!matched) return null;
return {
type: 'trigger',
dbName: matched.dbName,
triggerName: matched.rawObjectName,
tableName: matched.tableName,
schemaName: matched.schemaName || undefined,
};
};
const findRoutine = (candidateDbName: string, candidateRoutineName: string, schemaName = ''): QueryEditorNavigationTarget | null => {
const matched = findNamedObject(routineMetas, candidateDbName, candidateRoutineName, schemaName);
if (!matched) return null;
return {
type: 'routine',
dbName: matched.dbName,
routineName: matched.rawObjectName,
routineType: matched.routineType,
schemaName: matched.schemaName || undefined,
};
};
const findObjectInPriorityOrder = (candidateDbName: string, candidateObjectName: string, schemaName = ''): QueryEditorNavigationTarget | null => (
findTable(candidateDbName, candidateObjectName, schemaName)
|| findView(candidateDbName, candidateObjectName, schemaName)
|| findMaterializedView(candidateDbName, candidateObjectName, schemaName)
|| findTrigger(candidateDbName, candidateObjectName, schemaName)
|| findRoutine(candidateDbName, candidateObjectName, schemaName)
);
if (parts.length === 1) {
const [singlePart] = parts;
const normalizedSingle = singlePart.toLowerCase();
if (visibleDbSet.has(normalizedSingle)) {
return { type: 'database', dbName: singlePart };
}
return findObjectInPriorityOrder(currentDbName, singlePart);
}
if (parts.length === 2) {
const [firstPart, secondPart] = parts;
if (visibleDbSet.has(firstPart.toLowerCase())) {
return findObjectInPriorityOrder(firstPart, secondPart);
}
return findObjectInPriorityOrder(currentDbName, secondPart, firstPart);
}
const [dbName, schemaName, tableName] = parts;
if (!visibleDbSet.has(dbName.toLowerCase())) {
return null;
}
return findObjectInPriorityOrder(dbName, tableName, schemaName);
};
const resolveQueryEditorHoverTarget = (
fullText: string,
lineContent: string,
column: number,
currentDb: string,
visibleDbs: string[],
tables: CompletionTableMeta[],
allColumns: CompletionColumnMeta[],
views: CompletionViewMeta[] = [],
materializedViews: CompletionViewMeta[] = [],
triggers: CompletionTriggerMeta[] = [],
routines: CompletionRoutineMeta[] = [],
): QueryEditorHoverTarget | null => {
const text = String(lineContent || '');
if (!text) return null;
const offset = Math.max(0, Number(column || 1) - 2);
const windowRange = findIdentifierWindowAtOffset(text, offset);
if (!windowRange) return null;
const rawIdentifier = text.slice(windowRange.start, windowRange.end).trim();
if (!rawIdentifier) return null;
const range = { startColumn: windowRange.start + 1, endColumn: windowRange.end + 1 };
const parts = normalizeNavigationIdentifierParts(rawIdentifier);
if (parts.length === 0 || parts.length > 3) return null;
const findMatchingTable = (dbName: string, rawTableName: string, schemaName = ''): CompletionTableMeta | null => {
const normalizedDbName = String(dbName || '').trim().toLowerCase();
const normalizedRawTableName = String(rawTableName || '').trim().toLowerCase();
const normalizedSchemaName = String(schemaName || '').trim().toLowerCase();
return tables.find((item) => {
if (String(item.dbName || '').trim().toLowerCase() !== normalizedDbName) return false;
const itemRawName = String(item.tableName || '').trim();
const parsed = splitSidebarQualifiedName(itemRawName);
const itemObjectName = String(parsed.objectName || itemRawName).trim().toLowerCase();
const itemSchemaName = String(parsed.schemaName || '').trim().toLowerCase();
if (normalizedSchemaName) {
return itemSchemaName === normalizedSchemaName && (itemObjectName === normalizedRawTableName || String(itemRawName).trim().toLowerCase() === `${normalizedSchemaName}.${normalizedRawTableName}`);
}
return itemObjectName === normalizedRawTableName || String(itemRawName).trim().toLowerCase() === normalizedRawTableName;
}) || null;
};
const navigationTarget = resolveQueryEditorNavigationTarget(
lineContent,
column,
currentDb,
visibleDbs,
tables,
views,
materializedViews,
triggers,
routines,
);
if (navigationTarget) {
if (navigationTarget.type === 'database') {
return { kind: 'database', dbName: navigationTarget.dbName, range };
}
if (navigationTarget.type === 'table') {
const meta = findMatchingTable(navigationTarget.dbName, navigationTarget.tableName, navigationTarget.schemaName || '');
return {
kind: 'table',
dbName: navigationTarget.dbName,
tableName: navigationTarget.tableName,
schemaName: navigationTarget.schemaName,
comment: meta?.comment,
range,
};
}
if (navigationTarget.type === 'view') {
return { kind: 'view', dbName: navigationTarget.dbName, viewName: navigationTarget.viewName, schemaName: navigationTarget.schemaName, range };
}
if (navigationTarget.type === 'materialized-view') {
return { kind: 'materialized-view', dbName: navigationTarget.dbName, viewName: navigationTarget.viewName, schemaName: navigationTarget.schemaName, range };
}
if (navigationTarget.type === 'trigger') {
return { kind: 'trigger', dbName: navigationTarget.dbName, triggerName: navigationTarget.triggerName, tableName: navigationTarget.tableName, schemaName: navigationTarget.schemaName, range };
}
return { kind: 'routine', dbName: navigationTarget.dbName, routineName: navigationTarget.routineName, routineType: navigationTarget.routineType, schemaName: navigationTarget.schemaName, range };
}
const findColumnTarget = (dbName: string, tableName: string, columnName: string): QueryEditorHoverTarget | null => {
const normalizedDbName = String(dbName || '').trim().toLowerCase();
const normalizedTableName = String(tableName || '').trim().toLowerCase();
const normalizedColumnName = String(columnName || '').trim().toLowerCase();
const column = allColumns.find((item) => {
if (String(item.dbName || '').trim().toLowerCase() !== normalizedDbName) return false;
if (String(item.name || '').trim().toLowerCase() !== normalizedColumnName) return false;
const rawTable = String(item.tableName || '').trim().toLowerCase();
const parsed = splitCompletionSchemaAndTable(item.tableName || '');
return rawTable === normalizedTableName || String(parsed.table || '').trim().toLowerCase() === normalizedTableName;
});
if (!column) return null;
const parsedTable = splitCompletionSchemaAndTable(column.tableName || '');
return {
kind: 'column',
dbName: column.dbName,
tableName: column.tableName,
columnName: column.name,
type: column.type,
comment: column.comment,
schemaName: parsedTable.schema || undefined,
range,
};
};
if (parts.length === 2) {
const [firstPart, secondPart] = parts;
const aliasMap = buildQueryEditorAliasMap(fullText, currentDb);
const aliasInfo = aliasMap[firstPart.toLowerCase()];
if (aliasInfo) {
const aliasedColumn = findColumnTarget(aliasInfo.dbName, aliasInfo.tableName, secondPart);
if (aliasedColumn) return aliasedColumn;
}
const qualifiedTable = findMatchingTable(currentDb, secondPart, firstPart);
if (qualifiedTable) {
return {
kind: 'table',
dbName: qualifiedTable.dbName,
tableName: qualifiedTable.tableName,
schemaName: firstPart,
comment: qualifiedTable.comment,
range,
};
}
}
if (parts.length === 1) {
const [columnName] = parts;
const normalizedCurrentDb = String(currentDb || '').trim().toLowerCase();
const directColumns = allColumns.filter((item) =>
String(item.dbName || '').trim().toLowerCase() === normalizedCurrentDb
&& String(item.name || '').trim().toLowerCase() === columnName.toLowerCase()
);
if (directColumns.length === 1) {
const column = directColumns[0];
const parsedTable = splitCompletionSchemaAndTable(column.tableName || '');
return {
kind: 'column',
dbName: column.dbName,
tableName: column.tableName,
columnName: column.name,
type: column.type,
comment: column.comment,
schemaName: parsedTable.schema || undefined,
range,
};
}
}
return null;
};
export const resolveQueryEditorNavigationDecorations = (
lineContent: string,
column: number,
currentDb: string,
visibleDbs: string[],
tables: CompletionTableMeta[],
views: CompletionViewMeta[] = [],
materializedViews: CompletionViewMeta[] = [],
triggers: CompletionTriggerMeta[] = [],
routines: CompletionRoutineMeta[] = [],
shortcutModifierLabel = 'Ctrl/Cmd',
): Array<{ startColumn: number; endColumn: number; hoverMessage: string }> => {
const text = String(lineContent || '');
if (!text) return [];
const offset = Math.max(0, Number(column || 1) - 2);
const windowRange = findIdentifierWindowAtOffset(text, offset);
if (!windowRange) return [];
const navigationTarget = resolveQueryEditorNavigationTarget(
lineContent,
column,
currentDb,
visibleDbs,
tables,
views,
materializedViews,
triggers,
routines,
);
if (!navigationTarget) return [];
const hoverMessage = (() => {
if (navigationTarget.type === 'database') {
return `${shortcutModifierLabel} + 点击切换到该数据库`;
}
if (navigationTarget.type === 'table') {
return `${shortcutModifierLabel} + 点击打开该表`;
}
if (navigationTarget.type === 'view') {
return `${shortcutModifierLabel} + 点击打开该视图`;
}
if (navigationTarget.type === 'materialized-view') {
return `${shortcutModifierLabel} + 点击打开该物化视图`;
}
if (navigationTarget.type === 'trigger') {
return `${shortcutModifierLabel} + 点击打开该触发器`;
}
return navigationTarget.routineType === 'PROCEDURE'
? `${shortcutModifierLabel} + 点击打开该存储过程`
: `${shortcutModifierLabel} + 点击打开该函数`;
})();
return [{
startColumn: windowRange.start + 1,
endColumn: windowRange.end + 1,
hoverMessage,
}];
};
const buildQueryEditorNavigationHoverMarkdown = (
hoverTarget: QueryEditorHoverTarget | null,
actionHint: string,
): string => {
const hoverContent = hoverTarget ? buildQueryEditorHoverMarkdown(hoverTarget) : '';
return hoverContent
? `${hoverContent}\n\n---\n\n${actionHint}`
: actionHint;
};
const dispatchQueryEditorSidebarLocate = (detail: Record<string, unknown>) => {
if (typeof window === 'undefined') {
return;
}
const connectionId = String(detail.connectionId || '').trim();
const dbName = String(detail.dbName || '').trim();
const objectName = String(detail.tableName || detail.viewName || detail.triggerName || detail.routineName || detail.objectName || '').trim();
if (!connectionId || !dbName || !objectName) {
return;
}
window.dispatchEvent(new CustomEvent('gonavi:locate-sidebar-object', {
detail,
}));
};
const resolveEventTargetNode = (target: EventTarget | null): Node | null => (
typeof Node !== 'undefined' && target instanceof Node ? target : null
);
const clearQueryEditorLinkDecorations = (
editor: any,
decorationIdsRef: React.MutableRefObject<string[]>,
) => {
if (!editor?.deltaDecorations) {
decorationIdsRef.current = [];
return;
}
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, []);
};
const clearQueryEditorObjectDecorations = (
editor: any,
decorationIdsRef: React.MutableRefObject<string[]>,
) => {
if (!editor?.deltaDecorations) {
decorationIdsRef.current = [];
return;
}
decorationIdsRef.current = editor.deltaDecorations(decorationIdsRef.current, []);
};
const resolveQueryLocatorPlan = async ({
statement,
dbType,
currentDb,
config,
forceReadOnly,
}: {
statement: string;
dbType: string;
currentDb: string;
config: any;
forceReadOnly: boolean;
}): Promise<QueryStatementPlan> => {
const plan: QueryStatementPlan = {
originalSql: statement,
executedSql: statement,
pkColumns: [],
};
if (forceReadOnly) return plan;
const tableRef = extractQueryResultTableRef(statement, dbType, currentDb);
if (!tableRef) return plan;
plan.tableRef = tableRef;
const selectInfo = parseSimpleSelectInfo(statement);
if (!selectInfo) {
// 聚合、函数和表达式结果天然无法安全回写到单行,静默保持只读即可。
return plan;
}
if (!selectInfo.selectsAll && Object.keys(selectInfo.writableColumns).length === 0) {
return plan;
}
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
]);
if (!resCols?.success || !Array.isArray(resCols.data)) {
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
const tableColumns = resCols.data as ColumnDefinition[];
const tableColumnNames = tableColumns.map(getColumnDefinitionName).filter(Boolean);
const primaryKeys = tableColumns
.filter((column: any) => getColumnDefinitionKey(column) === 'PRI')
.map(getColumnDefinitionName)
.filter(Boolean);
const indexes = resIndexes?.success && Array.isArray(resIndexes.data)
? resIndexes.data as IndexDefinition[]
: [];
const writableColumns: Record<string, string> = selectInfo.selectsAll
? Object.fromEntries(tableColumnNames.map((column) => [column, column]))
: {};
Object.entries(selectInfo.writableColumns).forEach(([resultColumn, sourceColumn]) => {
const metadataColumn = resolveMetadataColumnName(tableColumnNames, sourceColumn);
if (metadataColumn) writableColumns[resultColumn] = metadataColumn;
});
const appendExpressions: string[] = [];
const hiddenColumns: string[] = [];
let needsOracleRowIDExpression = false;
const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => {
const valueColumns = locatorColumns.map((column, index) => {
const selectedColumn = findWritableResultColumnForSource(writableColumns, column);
if (selectedColumn) return selectedColumn;
const alias = buildQueryLocatorAlias(column, index + 1);
appendExpressions.push(buildQueryLocatorColumnExpression(dbType, column, alias));
hiddenColumns.push(alias);
return alias;
});
return {
strategy,
columns: locatorColumns,
valueColumns,
hiddenColumns: hiddenColumns.length > 0 ? [...hiddenColumns] : undefined,
writableColumns,
readOnly: false,
};
};
if (primaryKeys.length > 0) {
plan.pkColumns = primaryKeys;
plan.editLocator = buildColumnLocator('primary-key', primaryKeys);
} else {
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0);
if (uniqueKeyGroup) {
plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup);
} else if (isOracleLikeDialect(dbType)) {
needsOracleRowIDExpression = true;
plan.editLocator = {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
writableColumns,
readOnly: false,
};
} else {
const reason = !resIndexes?.success
? '无法加载唯一索引元数据,无法安全提交修改。'
: '未检测到主键或可用唯一索引,无法安全提交修改。';
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${tableRef.metadataDbName}.${tableRef.metadataTableName} ${reason}`;
}
}
const executableAppendExpressions = [
...(needsOracleRowIDExpression ? [buildQueryRowIDExpression(dbType)] : []),
...appendExpressions,
];
if (executableAppendExpressions.length > 0 && isOracleLikeDialect(dbType) && selectInfo.selectsBareAll) {
const rewritten = rewriteOracleSelectAllWithExpressions(statement, executableAppendExpressions);
if (rewritten) {
plan.executedSql = rewritten;
return plan;
}
const reason = 'Oracle 查询使用 * 时无法自动注入 ROWID 定位列,已保持只读。';
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
plan.executedSql = appendQuerySelectExpressions(statement, executableAppendExpressions);
return plan;
} catch {
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
};
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const [query, setQuery] = useState(getInitialEditorQuery(tab));
const isExternalSQLFileTab = Boolean(String(tab.filePath || '').trim());
type ResultSet = {
key: string;
sql: string;
exportSql?: string;
rows: any[];
columns: string[];
tableName?: string;
pkColumns: string[];
editLocator?: EditRowLocator;
readOnly: boolean;
truncated?: boolean;
pkLoading?: boolean;
};
// Result Sets
const [resultSets, setResultSets] = useState<ResultSet[]>([]);
const [activeResultKey, setActiveResultKey] = useState<string>('');
const [loading, setLoading] = useState(false);
const [executionError, setExecutionError] = useState<string>('');
const [, setCurrentQueryId] = useState<string>('');
const runSeqRef = useRef(0);
const currentQueryIdRef = useRef('');
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save');
const [saveForm] = Form.useForm();
// Database Selection
const [currentConnectionId, setCurrentConnectionId] = useState<string>(tab.connectionId);
const [currentDb, setCurrentDb] = useState<string>(tab.dbName || '');
const [dbList, setDbList] = useState<string[]>([]);
// Resizing state
const [editorHeight, setEditorHeight] = useState(300);
const editorShellRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const runQueryActionRef = useRef<any>(null);
const selectCurrentStatementActionRef = useRef<any>(null);
const saveQueryActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(getTabQueryValue(tab));
const lastEditorCursorPositionRef = useRef<any>(null);
const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null);
const lastExecutedEditorQueryRef = useRef<string>('');
const linkDecorationIdsRef = useRef<string[]>([]);
const ctrlMetaPressedRef = useRef(false);
const objectDecorationIdsRef = useRef<string[]>([]);
const objectHoverActionRef = useRef<any>(null);
const hoverProviderDisposableRef = useRef<any>(null);
const dragRef = useRef<{ startY: number, startHeight: number, currentHeight: number } | null>(null);
const pendingEditorHeightRef = useRef(editorHeight);
const resizeFrameRef = useRef<number | null>(null);
const queryEditorRootRef = useRef<HTMLDivElement | null>(null);
const editorPaneRef = useRef<HTMLDivElement | null>(null);
const tablesRef = useRef<CompletionTableMeta[]>([]); // Store tables for autocomplete (cross-db)
const allColumnsRef = useRef<CompletionColumnMeta[]>([]); // Store all columns (cross-db)
const viewsRef = useRef<CompletionViewMeta[]>([]);
const materializedViewsRef = useRef<CompletionViewMeta[]>([]);
const triggersRef = useRef<CompletionTriggerMeta[]>([]);
const routinesRef = useRef<CompletionRoutineMeta[]>([]);
const visibleDbsRef = useRef<string[]>([]); // 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 addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft);
const savedQueries = useStore(state => state.savedQueries);
const currentConnectionIdRef = useRef(currentConnectionId);
const currentDbRef = useRef(currentDb);
const connectionsRef = useRef(connections);
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
const saveQuery = useStore(state => state.saveQuery);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const shortcutOptions = useStore(state => state.shortcutOptions);
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const runQueryShortcutBinding = useMemo(
() => resolveShortcutBinding(shortcutOptions, 'runQuery', activeShortcutPlatform),
[activeShortcutPlatform, shortcutOptions],
);
const selectCurrentStatementShortcutBinding = useMemo(
() => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform),
[activeShortcutPlatform, shortcutOptions],
);
const saveQueryShortcutBinding = useMemo(
() => resolveShortcutBinding(shortcutOptions, 'saveQuery', activeShortcutPlatform),
[activeShortcutPlatform, shortcutOptions],
);
const primaryShortcutModifierLabel = useMemo(
() => getShortcutPrimaryModifierDisplayLabel(activeShortcutPlatform),
[activeShortcutPlatform],
);
const autoFetchVisible = useAutoFetchVisibility();
const currentSavedQuery = useMemo(() => {
const savedId = String(tab.savedQueryId || '').trim();
if (savedId) {
return savedQueries.find((item) => item.id === savedId) || null;
}
const tabId = String(tab.id || '').trim();
if (!tabId) {
return null;
}
return savedQueries.find((item) => item.id === tabId) || null;
}, [savedQueries, tab.id, tab.savedQueryId]);
const syncQueryDraft = useCallback((nextQuery: string) => {
const next = String(nextQuery ?? '');
if (isExternalSQLFileTab) {
setSQLFileTabDraft(tab.id, next);
return;
}
setQueryTabDraft(tab.id, next);
}, [isExternalSQLFileTab, tab.id]);
const applyQueryState = useCallback((nextQuery: string) => {
const next = String(nextQuery ?? '');
syncQueryDraft(next);
if (!isExternalSQLFileTab || next.length <= QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH) {
setQuery(next);
}
}, [isExternalSQLFileTab, syncQueryDraft]);
useEffect(() => {
setQueryTabDraft(tab.id, query);
}, [query, tab.id]);
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(() => {
if (isExternalSQLFileTab) return;
const currentDraft = getQueryTabDraft(tab.id, query);
const shouldPersistQuery = currentDraft.length <= QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH;
updateQueryTabDraft(tab.id, {
...(shouldPersistQuery ? { query: currentDraft } : {}),
connectionId: currentConnectionId,
dbName: currentDb,
});
}, [currentConnectionId, currentDb, isExternalSQLFileTab, query, tab.id, updateQueryTabDraft]);
useEffect(() => {
if (!isExternalSQLFileTab) return;
updateQueryTabDraft(tab.id, {
connectionId: currentConnectionId,
dbName: currentDb,
});
}, [currentConnectionId, currentDb, isExternalSQLFileTab, tab.id, updateQueryTabDraft]);
useEffect(() => {
if (!isExternalSQLFileTab) return;
setSQLFileTabDraft(tab.id, getCurrentQuery());
return () => {
setSQLFileTabDraft(tab.id, getCurrentQuery());
};
}, [isExternalSQLFileTab, tab.id]);
// 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量
// 确保 completion provider 始终使用当前活跃 Tab 的上下文
useEffect(() => {
if (!isActive) return;
sharedCurrentDb = currentDb;
sharedCurrentConnectionId = currentConnectionId;
sharedConnections = connections;
sharedTablesData = tablesRef.current;
sharedAllColumnsData = allColumnsRef.current;
sharedVisibleDbs = visibleDbsRef.current;
sharedColumnsCacheData = columnsCacheRef.current;
}, [isActive, currentDb, currentConnectionId, connections]);
useEffect(() => {
connectionsRef.current = connections;
}, [connections]);
const refreshObjectDecorations = useCallback((maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH) => {
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
if (!editor || !monaco || !model) {
return;
}
const text = getQueryEditorModelTextIfWithinLimit(model, maxTextLength);
if (text === null) {
objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, []);
return;
}
const decorations: any[] = [];
const seen = new Set<string>();
let scannedIdentifiers = 0;
const identifierRegex = /[`"\[]?[A-Za-z_][A-Za-z0-9_$]*(?:[`"\]]?\s*\.\s*[`"\[]?[A-Za-z_][A-Za-z0-9_$]*){0,2}[`"\]]?/g;
const lines = text.replace(/\r\n/g, '\n').split('\n');
for (const [lineIndex, lineContent] of lines.entries()) {
let match: RegExpExecArray | null;
identifierRegex.lastIndex = 0;
while ((match = identifierRegex.exec(lineContent)) !== null) {
scannedIdentifiers += 1;
if (scannedIdentifiers > QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS) {
break;
}
const positionColumn = match.index + 2;
const hoverTarget = resolveQueryEditorHoverTarget(
text,
lineContent,
positionColumn,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
allColumnsRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
);
if (!hoverTarget) continue;
const inlineClassName = hoverTarget.kind === 'column'
? 'gonavi-query-editor-column-token'
: hoverTarget.kind === 'database'
? 'gonavi-query-editor-db-token'
: 'gonavi-query-editor-object-token';
const key = `${lineIndex + 1}:${hoverTarget.range.startColumn}:${hoverTarget.range.endColumn}:${inlineClassName}`;
if (seen.has(key)) continue;
seen.add(key);
decorations.push({
range: new monaco.Range(
lineIndex + 1,
hoverTarget.range.startColumn,
lineIndex + 1,
hoverTarget.range.endColumn,
),
options: { inlineClassName },
});
}
if (scannedIdentifiers > QUERY_EDITOR_OBJECT_DECORATION_MAX_IDENTIFIERS) {
break;
}
}
objectDecorationIdsRef.current = editor.deltaDecorations(objectDecorationIdsRef.current, decorations);
}, []);
const showObjectInfoAtPosition = useCallback((position?: { lineNumber: number; column: number } | null) => {
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
const normalizedPosition = normalizeEditorPosition(position || editor?.getPosition?.());
if (!editor || !model || !normalizedPosition) {
return false;
}
const lineContent = String(model.getLineContent?.(normalizedPosition.lineNumber) || '');
const resolveText = getQueryEditorObjectResolveText(model, lineContent);
const hoverTarget = resolveQueryEditorHoverTarget(
resolveText,
lineContent,
normalizedPosition.column,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
allColumnsRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
);
if (!hoverTarget) {
return false;
}
editor.focus?.();
const hoverRange = monaco
? new monaco.Range(
normalizedPosition.lineNumber,
hoverTarget.range.startColumn,
normalizedPosition.lineNumber,
hoverTarget.range.endColumn,
)
: {
startLineNumber: normalizedPosition.lineNumber,
startColumn: hoverTarget.range.startColumn,
endLineNumber: normalizedPosition.lineNumber,
endColumn: hoverTarget.range.endColumn,
};
const contentHoverController = editor.getContribution?.('editor.contrib.contentHover');
if (contentHoverController?.showContentHover) {
contentHoverController.showContentHover(hoverRange, 1, 2, false);
return true;
}
editor.setPosition?.({
lineNumber: normalizedPosition.lineNumber,
column: hoverTarget.range.startColumn,
});
editor.trigger?.('gonavi-hover', 'editor.action.showHover', null);
return true;
}, []);
useEffect(() => {
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
}, [currentDb, refreshObjectDecorations]);
const getCurrentQuery = () => {
const val = editorRef.current?.getValue?.();
if (typeof val === 'string') return val;
return query || '';
};
const handleSelectCurrentStatement = () => {
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
const position = editor?.getPosition?.();
if (!editor || !monaco || !model || !position) {
return;
}
const fullSQL = String(model.getValue?.() || '');
const cursorOffset = model.getOffsetAt?.(position);
const range = resolveCurrentSqlStatementRange(fullSQL, Number(cursorOffset));
if (!range) {
void message.info('没有可选择的 SQL 语句。');
return;
}
const start = model.getPositionAt(range.start);
const end = model.getPositionAt(range.end);
const selection = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
editor.setSelection(selection);
editor.revealRangeInCenterIfOutsideViewport?.(selection);
editor.focus?.();
};
const syncQueryToEditor = (sql: string) => {
const next = sql || '';
applyQueryState(next);
const editor = editorRef.current;
if (editor && editor.getValue?.() !== next) {
editor.setValue(next);
}
};
// If opening a saved query, load its SQL
useEffect(() => {
const incoming = getTabQueryValue(tab);
if (incoming === lastExternalQueryRef.current) {
return;
}
lastExternalQueryRef.current = incoming;
syncQueryToEditor(incoming);
}, [tab.id, tab.query]);
// Fetch Database List
useEffect(() => {
if (!autoFetchVisible) {
return;
}
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(buildRpcConnectionConfig(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;
if (isActive) {
sharedVisibleDbs = dbs;
}
setDbList(dbs);
if (!currentDbRef.current) {
const configuredDb = String(conn.config.database || '').trim();
const fallbackDb = dbs.find((db: string) => String(db || '').toLowerCase() !== 'information_schema') || dbs[0] || '';
const nextDb = configuredDb && dbs.includes(configuredDb) ? configuredDb : fallbackDb;
if (nextDb) {
currentDbRef.current = nextDb;
setCurrentDb(nextDb);
}
}
} else {
visibleDbsRef.current = [];
if (isActive) {
sharedVisibleDbs = [];
}
setDbList([]);
}
};
void fetchDbs();
}, [autoFetchVisible, currentConnectionId, connections]);
// Fetch Metadata for Autocomplete (Cross-database)
useEffect(() => {
if (!autoFetchVisible) {
return;
}
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: CompletionTableMeta[] = [];
const allColumns: CompletionColumnMeta[] = [];
const allViews: CompletionViewMeta[] = [];
const allMaterializedViews: CompletionViewMeta[] = [];
const allTriggers: CompletionTriggerMeta[] = [];
const allRoutines: CompletionRoutineMeta[] = [];
const metadataDialect = normalizeMetadataDialect(conn);
for (const dbName of visibleDbs) {
const tableComments = new Map<string, string>();
const tableCommentSQL = buildCompletionTableCommentSQL(metadataDialect, dbName);
if (tableCommentSQL) {
try {
const resTableComments = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, tableCommentSQL);
if (resTableComments.success && Array.isArray(resTableComments.data)) {
resTableComments.data.forEach((row: any) => {
const tableName = normalizeCommentText(getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'name', 'Name']));
if (!tableName) return;
tableComments.set(tableName.toLowerCase(), normalizeCommentText(getCaseInsensitiveValue(row, ['table_comment', 'TABLE_COMMENT', 'comment', 'comments', 'Comment', 'COMMENTS'])));
});
}
} catch {
// 表备注只是补全增强,失败时保留原有表名补全。
}
}
// 获取表
const resTables = await DBGetTables(buildRpcConnectionConfig(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) => {
const parsed = splitCompletionSchemaAndTable(String(tableName || ''));
allTables.push({
dbName,
tableName,
comment: tableComments.get(String(tableName || '').toLowerCase())
|| (parsed.table ? tableComments.get(parsed.table.toLowerCase()) : undefined)
|| undefined
});
});
}
// 获取列 (所有数据库类型都支持 DBGetAllColumns)
const resCols = await DBGetAllColumns(buildRpcConnectionConfig(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,
comment: normalizeCommentText(col.comment ?? col.Comment ?? col.COLUMN_COMMENT ?? col.column_comment ?? '')
});
});
}
const viewResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionViewsMetadataQuerySpecs(metadataDialect, dbName),
);
const seenViews = new Set<string>();
viewResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
const tableType = String(getCaseInsensitiveValue(row, ['table_type', 'table type', 'type']) || '').trim().toUpperCase();
if (tableType && tableType !== 'VIEW') return;
const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'table_schema', 'db']) || '').trim();
const rawViewName = String(getCaseInsensitiveValue(row, ['view_name', 'viewname', 'table_name', 'name']) || '').trim() || getFirstRowValue(row);
const normalizedViewName = normalizeSidebarViewName(metadataDialect, dbName, schemaName, rawViewName);
if (!normalizedViewName) return;
const uniqueKey = `${dbName.toLowerCase()}@@${normalizedViewName.toLowerCase()}`;
if (seenViews.has(uniqueKey)) return;
seenViews.add(uniqueKey);
const parsed = splitSidebarQualifiedName(normalizedViewName);
allViews.push({
dbName,
viewName: normalizedViewName,
schemaName: schemaName || parsed.schemaName || undefined,
});
});
});
const materializedViewResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionMaterializedViewsMetadataQuerySpecs(metadataDialect, dbName),
);
const seenMaterializedViews = new Set<string>();
materializedViewResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'table_schema', 'db', 'database']) || '').trim();
const rawViewName = String(getCaseInsensitiveValue(row, ['object_name', 'view_name', 'table_name', 'name', 'materialized_view_name', 'mv_name']) || '').trim() || getFirstRowValue(row);
const normalizedViewName = normalizeSidebarViewName(metadataDialect, dbName, schemaName, rawViewName);
if (!normalizedViewName) return;
const uniqueKey = `${dbName.toLowerCase()}@@${normalizedViewName.toLowerCase()}`;
if (seenMaterializedViews.has(uniqueKey)) return;
seenMaterializedViews.add(uniqueKey);
const parsed = splitSidebarQualifiedName(normalizedViewName);
allMaterializedViews.push({
dbName,
viewName: normalizedViewName,
schemaName: schemaName || parsed.schemaName || undefined,
});
});
});
const triggerResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionTriggersMetadataQuerySpecs(metadataDialect, dbName),
);
const seenTriggers = new Set<string>();
triggerResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
const rawTriggerName = String(getCaseInsensitiveValue(row, ['trigger_name', 'triggername', 'trigger', 'name']) || '').trim() || getFirstRowValue(row);
if (!rawTriggerName) return;
const rawSchemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'schemaname', 'owner', 'event_object_schema', 'trigger_schema', 'db']) || '').trim();
const rawTableName = String(getCaseInsensitiveValue(row, ['table_name', 'event_object_table', 'tbl_name', 'table']) || '').trim();
const triggerParts = splitSidebarQualifiedName(rawTriggerName);
const tableParts = splitSidebarQualifiedName(rawTableName);
const resolvedSchemaName = String(rawSchemaName || tableParts.schemaName || triggerParts.schemaName || '').trim();
const resolvedTriggerName = String(triggerParts.objectName || rawTriggerName).trim();
const resolvedTableName = buildQualifiedCompletionName(resolvedSchemaName, tableParts.objectName || rawTableName);
const uniqueKey = (metadataDialect === 'mysql' || metadataDialect === 'starrocks')
? `${dbName.toLowerCase()}@@${resolvedSchemaName.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}`
: `${dbName.toLowerCase()}@@${resolvedSchemaName.toLowerCase()}@@${resolvedTriggerName.toLowerCase()}@@${resolvedTableName.toLowerCase()}`;
if (seenTriggers.has(uniqueKey)) return;
seenTriggers.add(uniqueKey);
allTriggers.push({
dbName,
triggerName: buildQualifiedCompletionName(resolvedSchemaName, resolvedTriggerName) || resolvedTriggerName,
tableName: resolvedTableName || rawTableName,
schemaName: resolvedSchemaName || undefined,
});
});
});
const routineResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionFunctionsMetadataQuerySpecs(metadataDialect, dbName),
);
const seenRoutines = new Set<string>();
routineResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
const rawRoutineName = String(getCaseInsensitiveValue(row, ['routine_name', 'object_name', 'proname', 'name']) || '').trim();
if (!rawRoutineName) return;
const schemaName = String(getCaseInsensitiveValue(row, ['schema_name', 'nspname', 'owner', 'db', 'database']) || '').trim();
const rawType = String(getCaseInsensitiveValue(row, ['routine_type', 'object_type', 'type']) || queryResult.inferredType || 'FUNCTION').trim();
const normalizedType = rawType.toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION';
const qualifiedRoutineName = buildQualifiedCompletionName(schemaName, rawRoutineName);
if (!qualifiedRoutineName) return;
const uniqueKey = `${dbName.toLowerCase()}@@${qualifiedRoutineName.toLowerCase()}@@${normalizedType}`;
if (seenRoutines.has(uniqueKey)) return;
seenRoutines.add(uniqueKey);
allRoutines.push({
dbName,
routineName: qualifiedRoutineName,
routineType: normalizedType,
schemaName: schemaName || splitSidebarQualifiedName(qualifiedRoutineName).schemaName || undefined,
});
});
});
}
tablesRef.current = allTables;
allColumnsRef.current = allColumns;
viewsRef.current = allViews;
materializedViewsRef.current = allMaterializedViews;
triggersRef.current = allTriggers;
routinesRef.current = allRoutines;
// 如果当前 Tab 是活跃 Tab同步更新共享变量
if (isActive) {
sharedTablesData = allTables;
sharedAllColumnsData = allColumns;
}
refreshObjectDecorations();
};
void fetchMetadata();
}, [autoFetchVisible, currentConnectionId, connections, dbList, isActive, refreshObjectDecorations]); // dbList 变化时触发重新加载
// Query ID management helpers
const setQueryId = (id: string) => {
currentQueryIdRef.current = id;
setCurrentQueryId(id);
};
const clearQueryId = () => {
currentQueryIdRef.current = '';
setCurrentQueryId('');
};
const clampEditorHeight = useCallback((height: number) => {
const viewportHeight = Number.isFinite(window.innerHeight) ? window.innerHeight : 800;
const maxHeight = Math.max(100, viewportHeight - 200);
return Math.max(100, Math.min(maxHeight, height));
}, []);
const applyEditorHeightToDom = useCallback(() => {
const nextHeight = pendingEditorHeightRef.current;
if (editorShellRef.current) {
editorShellRef.current.style.height = `${nextHeight}px`;
}
editorRef.current?.layout?.();
}, []);
const cancelEditorResizeFrame = useCallback(() => {
if (resizeFrameRef.current === null) return;
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(resizeFrameRef.current);
} else {
window.clearTimeout(resizeFrameRef.current);
}
resizeFrameRef.current = null;
}, []);
const scheduleEditorHeightDomUpdate = useCallback((height: number) => {
pendingEditorHeightRef.current = height;
if (resizeFrameRef.current !== null) return;
const requestFrame = typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame.bind(window)
: (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 16);
resizeFrameRef.current = requestFrame(() => {
resizeFrameRef.current = null;
applyEditorHeightToDom();
});
}, [applyEditorHeightToDom]);
// Handle Resizing
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!dragRef.current) return;
const delta = e.clientY - dragRef.current.startY;
const newHeight = clampEditorHeight(dragRef.current.startHeight + delta);
dragRef.current.currentHeight = newHeight;
scheduleEditorHeightDomUpdate(newHeight);
}, [clampEditorHeight, scheduleEditorHeightDomUpdate]);
const handleMouseUp = useCallback(() => {
const finalHeight = dragRef.current?.currentHeight;
dragRef.current = null;
cancelEditorResizeFrame();
if (typeof finalHeight === 'number') {
pendingEditorHeightRef.current = finalHeight;
applyEditorHeightToDom();
setEditorHeight(finalHeight);
}
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}, [applyEditorHeightToDom, cancelEditorResizeFrame, handleMouseMove]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight, currentHeight: editorHeight };
pendingEditorHeightRef.current = editorHeight;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [editorHeight, handleMouseMove, handleMouseUp]);
useEffect(() => {
return () => {
dragRef.current = null;
cancelEditorResizeFrame();
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [cancelEditorResizeFrame, handleMouseMove, handleMouseUp]);
// Setup Autocomplete and Editor
const handleEditorDidMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
lastEditorCursorPositionRef.current = normalizeEditorPosition(editor.getPosition?.());
editor.updateOptions?.({
fixedOverflowWidgets: true,
hover: {
enabled: true,
delay: QUERY_EDITOR_HOVER_DELAY_MS,
above: false,
},
});
const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => {
if (!ctrlMetaPressedRef.current) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
if (!targetPosition) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
const model = editor.getModel?.();
const lineContent = String(model?.getLineContent?.(targetPosition.lineNumber) || '');
const decorations = resolveQueryEditorNavigationDecorations(
lineContent,
targetPosition.column,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
primaryShortcutModifierLabel,
);
if (decorations.length === 0) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
const hoverTarget = resolveQueryEditorHoverTarget(
getQueryEditorObjectResolveText(model, lineContent),
lineContent,
targetPosition.column,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
allColumnsRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
);
linkDecorationIdsRef.current = editor.deltaDecorations(
linkDecorationIdsRef.current,
decorations.map((item) => ({
range: new monaco.Range(
targetPosition.lineNumber,
item.startColumn,
targetPosition.lineNumber,
item.endColumn,
),
options: {
inlineClassName: 'gonavi-query-editor-link-hint',
hoverMessage: {
value: buildQueryEditorNavigationHoverMarkdown(hoverTarget, item.hoverMessage),
},
},
})),
);
setQueryEditorMouseCursor(editor, 'pointer');
};
const applyNavigationHoverState = (event: any) => {
const targetPosition = normalizeEditorPosition(event?.target?.position);
lastHoverTargetPositionRef.current = targetPosition;
applyNavigationHoverStateAtPosition(targetPosition);
};
const syncModifierState = (keyboardEvent?: KeyboardEvent | MouseEvent | null) => {
const wasPressed = ctrlMetaPressedRef.current;
ctrlMetaPressedRef.current = !!(keyboardEvent?.ctrlKey || keyboardEvent?.metaKey);
if (!ctrlMetaPressedRef.current) {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
return;
}
const isKeyboardLikeEvent = keyboardEvent
&& typeof keyboardEvent === 'object'
&& ('key' in keyboardEvent || 'code' in keyboardEvent || 'repeat' in keyboardEvent);
if (!wasPressed || isKeyboardLikeEvent) {
applyNavigationHoverStateAtPosition(lastHoverTargetPositionRef.current);
}
};
const handleWindowBlur = () => {
ctrlMetaPressedRef.current = false;
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
};
// 应用透明主题(主题由 MonacoEditor 包装组件按需注册)
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
hoverProviderDisposableRef.current?.dispose?.();
hoverProviderDisposableRef.current = monaco.languages.registerHoverProvider('sql', {
provideHover: (model: any, position: any) => {
const normalizedPosition = normalizeEditorPosition(position);
if (!normalizedPosition) {
return null;
}
const lineContent = String(model?.getLineContent?.(normalizedPosition.lineNumber) || '');
const resolveText = getQueryEditorObjectResolveText(model, lineContent);
const hoverTarget = resolveQueryEditorHoverTarget(
resolveText,
lineContent,
normalizedPosition.column,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
allColumnsRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
);
if (!hoverTarget) {
return null;
}
return {
range: new monaco.Range(
normalizedPosition.lineNumber,
hoverTarget.range.startColumn,
normalizedPosition.lineNumber,
hoverTarget.range.endColumn,
),
contents: [{ value: buildQueryEditorHoverMarkdown(hoverTarget) }],
};
},
});
objectHoverActionRef.current?.dispose?.();
const showObjectInfoKeybinding = monaco.KeyMod?.CtrlCmd && monaco.KeyCode?.KeyQ
? [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyQ]
: undefined;
objectHoverActionRef.current = editor.addAction({
id: 'gonavi.queryEditor.showObjectInfo',
label: 'GoNavi: 查看对象信息',
keybindings: showObjectInfoKeybinding,
run: () => {
const preferredPosition = lastHoverTargetPositionRef.current || editor.getPosition?.();
const shown = showObjectInfoAtPosition(preferredPosition);
if (!shown) {
void message.info({
key: 'gonavi-query-editor-object-info-miss',
content: '当前光标未定位到可识别的表或字段。',
});
}
},
});
editor.onDidChangeCursorPosition?.((event: any) => {
const position = normalizeEditorPosition(event?.position);
if (position) {
lastEditorCursorPositionRef.current = position;
}
});
editor.onDidChangeModelContent?.((event: any) => {
const hasSlashCommandMarker = Array.isArray(event?.changes)
&& event.changes.some((change: any) => /__AI_\w+__/.test(String(change?.text || '')));
if (!hasSlashCommandMarker) return;
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
});
editor.onMouseMove?.((event: any) => {
syncModifierState(event?.event || null);
applyNavigationHoverState(event);
});
editor.onMouseLeave?.(() => {
lastHoverTargetPositionRef.current = null;
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
editor.updateOptions?.({ mouseStyle: 'text' });
setQueryEditorMouseCursor(editor, '');
});
window.addEventListener('keydown', syncModifierState);
window.addEventListener('keyup', syncModifierState);
window.addEventListener('blur', handleWindowBlur);
editor.onMouseDown?.((event: any) => {
const browserEvent = event?.event;
syncModifierState(browserEvent || null);
const targetPosition = normalizeEditorPosition(event?.target?.position);
if (!browserEvent || !targetPosition) {
return;
}
if (browserEvent.leftButton !== true) {
return;
}
if (!browserEvent.ctrlKey && !browserEvent.metaKey) {
return;
}
const model = editor.getModel?.();
const lineContent = String(model?.getLineContent?.(targetPosition.lineNumber) || '');
const navigationTarget = resolveQueryEditorNavigationTarget(
lineContent,
targetPosition.column,
currentDbRef.current,
visibleDbsRef.current,
tablesRef.current,
viewsRef.current,
materializedViewsRef.current,
triggersRef.current,
routinesRef.current,
);
if (!navigationTarget) {
return;
}
browserEvent.preventDefault?.();
browserEvent.stopPropagation?.();
const connectionId = String(currentConnectionIdRef.current || '').trim();
if (!connectionId) {
return;
}
if (navigationTarget.type === 'database') {
const nextDbName = String(navigationTarget.dbName || '').trim();
if (!nextDbName) {
return;
}
setCurrentDb(nextDbName);
currentDbRef.current = nextDbName;
setActiveContext({ connectionId, dbName: nextDbName });
return;
}
const targetDbName = String(navigationTarget.dbName || '').trim();
if (!targetDbName) {
return;
}
setCurrentDb(targetDbName);
currentDbRef.current = targetDbName;
setActiveContext({ connectionId, dbName: targetDbName });
if (navigationTarget.type === 'table') {
const targetTableName = String(navigationTarget.tableName || '').trim();
if (!targetTableName) return;
addTab({
id: `${connectionId}-${targetDbName}-table-${targetTableName}`,
title: targetTableName,
type: 'table',
connectionId,
dbName: targetDbName,
tableName: targetTableName,
});
dispatchQueryEditorSidebarLocate({
connectionId,
dbName: targetDbName,
tableName: targetTableName,
schemaName: navigationTarget.schemaName,
objectGroup: 'tables',
});
return;
}
if (navigationTarget.type === 'view' || navigationTarget.type === 'materialized-view') {
const targetViewName = String(navigationTarget.viewName || '').trim();
if (!targetViewName) return;
addTab({
id: `view-def-${connectionId}-${targetDbName}-${targetViewName}`,
title: `${navigationTarget.type === 'materialized-view' ? '物化视图' : '视图'}: ${targetViewName}`,
type: 'view-def',
connectionId,
dbName: targetDbName,
viewName: targetViewName,
viewKind: navigationTarget.type === 'materialized-view' ? 'materialized' : 'view',
});
dispatchQueryEditorSidebarLocate({
connectionId,
dbName: targetDbName,
viewName: targetViewName,
tableName: targetViewName,
schemaName: navigationTarget.schemaName,
objectGroup: navigationTarget.type === 'materialized-view' ? 'materializedViews' : 'views',
});
return;
}
if (navigationTarget.type === 'trigger') {
const targetTriggerName = String(navigationTarget.triggerName || '').trim();
if (!targetTriggerName) return;
addTab({
id: `trigger-${connectionId}-${targetDbName}-${targetTriggerName}`,
title: `触发器: ${targetTriggerName}`,
type: 'trigger',
connectionId,
dbName: targetDbName,
triggerName: targetTriggerName,
});
dispatchQueryEditorSidebarLocate({
connectionId,
dbName: targetDbName,
triggerName: targetTriggerName,
tableName: targetTriggerName,
schemaName: navigationTarget.schemaName,
objectGroup: 'triggers',
});
return;
}
const targetRoutineName = String(navigationTarget.routineName || '').trim();
if (!targetRoutineName) return;
addTab({
id: `routine-def-${connectionId}-${targetDbName}-${targetRoutineName}`,
title: `${navigationTarget.routineType === 'PROCEDURE' ? '存储过程' : '函数'}: ${targetRoutineName}`,
type: 'routine-def',
connectionId,
dbName: targetDbName,
routineName: targetRoutineName,
routineType: navigationTarget.routineType,
});
dispatchQueryEditorSidebarLocate({
connectionId,
dbName: targetDbName,
routineName: targetRoutineName,
tableName: targetRoutineName,
schemaName: navigationTarget.schemaName,
objectGroup: 'routines',
});
});
editor.onDidDispose?.(() => {
clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef);
clearQueryEditorObjectDecorations(editor, objectDecorationIdsRef);
setQueryEditorMouseCursor(editor, '');
objectHoverActionRef.current?.dispose?.();
objectHoverActionRef.current = null;
hoverProviderDisposableRef.current?.dispose?.();
hoverProviderDisposableRef.current = null;
window.removeEventListener('keydown', syncModifierState);
window.removeEventListener('keyup', syncModifierState);
window.removeEventListener('blur', handleWindowBlur);
});
refreshObjectDecorations();
// 注册 AI 右键菜单操作
const aiActions = [
{ id: 'ai.generateSQL', label: '🤖 AI 生成 SQL', prompt: '请根据当前数据库表结构生成查询语句:' },
{ id: 'ai.explainSQL', label: '🤖 AI 解释 SQL', useSelection: true, prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n{SQL}\n```' },
{ id: 'ai.optimizeSQL', label: '🤖 AI 优化 SQL', useSelection: true, prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n{SQL}\n```' },
];
aiActions.forEach(action => {
editor.addAction({
id: action.id,
label: action.label,
contextMenuGroupId: '9_ai',
contextMenuOrder: 1,
run: (ed: any) => {
const selection = ed.getModel()?.getValueInRange(ed.getSelection());
const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current);
const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : '';
let prompt = ctxText + action.prompt;
if (action.useSelection && selection) {
prompt = prompt.replace('{SQL}', selection);
}
// 打开 AI 面板并填入 prompt
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
// 通过自定义事件将 prompt 发送到 AI 面板
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
},
});
});
// Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding
const runBinding = runQueryShortcutBinding;
if (runBinding?.enabled && runBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
runBinding.combo, monaco.KeyMod, monaco.KeyCode
);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
id: 'gonavi.runQuery',
label: 'GoNavi: 执行 SQL',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
},
});
}
}
const selectStatementBinding = selectCurrentStatementShortcutBinding;
if (selectStatementBinding?.enabled && selectStatementBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
selectStatementBinding.combo, monaco.KeyMod, monaco.KeyCode
);
if (keyBinding) {
selectCurrentStatementActionRef.current = editor.addAction({
id: 'gonavi.selectCurrentStatement',
label: 'GoNavi: 选择当前语句',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: handleSelectCurrentStatement,
});
}
}
const saveBinding = saveQueryShortcutBinding;
if (saveBinding?.enabled && saveBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
saveBinding.combo, monaco.KeyMod, monaco.KeyCode
);
if (keyBinding) {
saveQueryActionRef.current = editor.addAction({
id: 'gonavi.saveQuery',
label: 'GoNavi: 保存查询',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:save-active-query'));
},
});
}
}
// HMR 重载或测试重置时,以全局状态为准,避免本地闭包状态和 provider 列表不同步。
sqlCompletionRegistered = Boolean(_g.__gonaviSqlCompletionState.registered);
sqlCompletionDisposables = _g.__gonaviSqlCompletionState.disposables;
const shouldRegisterSqlCompletion = !sqlCompletionRegistered
|| _g.__gonaviSqlCompletionState.version !== SQL_COMPLETION_PROVIDER_VERSION;
// HMR 重载时释放旧注册避免补全项重复
if (shouldRegisterSqlCompletion) {
sqlCompletionRegistered = true;
_g.__gonaviSqlCompletionState.registered = true;
_g.__gonaviSqlCompletionState.version = SQL_COMPLETION_PROVIDER_VERSION;
sqlCompletionDisposables.forEach((d: any) => d?.dispose?.());
sqlCompletionDisposables.length = 0;
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.', '_', ...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')],
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 activeConnection = sharedConnections.find(c => c.id === sharedCurrentConnectionId);
const activeDialect = resolveSqlDialect(
String(activeConnection?.config?.type || ''),
String(activeConnection?.config?.driver || ''),
{ oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol },
);
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
const stripQuotes = stripCompletionIdentifierQuotes;
const normalizeQualifiedName = normalizeCompletionQualifiedName;
const getLastPart = getCompletionQualifiedNameLastPart;
const splitSchemaAndTable = splitCompletionSchemaAndTable;
const buildConnConfig = () => {
const connId = sharedCurrentConnectionId;
const conn = sharedConnections.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 getLazyTablesByDB = async (dbName: string) => {
const connId = sharedCurrentConnectionId;
if (!connId || !dbName) return [] as CompletionTableMeta[];
const key = `${connId}|${dbName}`;
if (sharedLazyTablesCache[key]) {
return sharedLazyTablesCache[key];
}
if (sharedLazyTablesInFlight[key]) {
return sharedLazyTablesInFlight[key];
}
const config = buildConnConfig();
if (!config) return [] as CompletionTableMeta[];
sharedLazyTablesInFlight[key] = DBGetTables(buildRpcConnectionConfig(config) as any, dbName)
.then((res) => {
const tables = res?.success && Array.isArray(res.data)
? res.data
.map((row: any) => String(Object.values(row || {})[0] || '').trim())
.filter(Boolean)
.map((tableName: string) => ({ dbName, tableName }))
: [];
sharedLazyTablesCache[key] = tables;
if (tables.length > 0) {
const existingKeys = new Set(sharedTablesData.map((table) => `${table.dbName.toLowerCase()}.${table.tableName.toLowerCase()}`));
const missingTables = tables.filter((table) => !existingKeys.has(`${table.dbName.toLowerCase()}.${table.tableName.toLowerCase()}`));
if (missingTables.length > 0) {
sharedTablesData = [...sharedTablesData, ...missingTables];
}
}
return tables;
})
.catch(() => [])
.finally(() => {
delete sharedLazyTablesInFlight[key];
});
return sharedLazyTablesInFlight[key];
};
const getColumnsByDB = async (tableIdent: string) => {
const connId = sharedCurrentConnectionId;
const dbName = sharedCurrentDb;
if (!connId || !dbName) return [] as ColumnDefinition[];
const key = `${connId}|${dbName}|${tableIdent}`;
const cached = sharedColumnsCacheData[key];
if (cached) return cached;
const config = buildConnConfig();
if (!config) return [] as ColumnDefinition[];
const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableIdent);
if (res?.success && Array.isArray(res.data)) {
const cols = res.data as ColumnDefinition[];
sharedColumnsCacheData[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 = sharedAllColumnsData.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: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment),
documentation: buildCompletionDocumentation(c.comment),
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 = sharedVisibleDbs;
if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) {
// qualifier 是数据库名,提示该库的表
const tables = sharedTablesData.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: appendCommentToDetail(`Table (${t.dbName})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: '0' + t.tableName
}));
return { suggestions };
}
// qualifier 是 schema如 dbo/public仅补全表名避免输入 dbo. 后再补成 dbo.dbo.table
const schemaTables = sharedTablesData
.map(t => {
const parsed = splitSchemaAndTable(t.tableName || '');
return {
dbName: t.dbName || '',
schema: parsed.schema,
table: parsed.table,
comment: t.comment,
};
})
.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: appendCommentToDetail(`Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
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<string, {dbName: string, tableName: string}> = {};
// 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 = sharedCurrentDb || '';
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, comment?: string }[];
if (sharedAllColumnsData.length > 0) {
const tiTableLower = (tableInfo.tableName || '').toLowerCase();
cols = sharedAllColumnsData
.filter(c => {
if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false;
const cTableLower = (c.tableName || '').toLowerCase();
if (cTableLower === tiTableLower) return true;
// schema.table 格式匹配纯表名
const parsed = splitSchemaAndTable(c.tableName || '');
return (parsed.table || '').toLowerCase() === tiTableLower;
})
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName, comment: c.comment }));
} else {
const dbCols = await getColumnsByDB(tableInfo.tableName);
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName, comment: c.comment }));
}
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: appendCommentToDetail(
c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''),
c.comment,
),
documentation: buildCompletionDocumentation(c.comment),
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<string>();
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 = sharedCurrentDb || '';
const wordPrefix = (word.word || '').toLowerCase();
const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix);
const includesWordPrefix = (candidate: string) => !wordPrefix || String(candidate || '').toLowerCase().includes(wordPrefix);
const getPrefixMatchRank = (...candidates: string[]) => {
if (!wordPrefix) return '0';
const normalized = candidates
.map((candidate) => String(candidate || '').toLowerCase())
.filter(Boolean);
if (normalized.some((candidate) => candidate.startsWith(wordPrefix))) return '0';
if (normalized.some((candidate) => candidate.includes(wordPrefix))) return '1';
return '9';
};
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
const shouldBoostKeywords = !expectsTableName
&& wordPrefix.length > 0
&& dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
const sortGroups = shouldBoostKeywords
? { keyword: '00', func: '05', columnCurrent: '10', columnOther: '11', tableCurrent: '20', tableOther: '21', db: '30' }
: expectsTableName
? { keyword: '20', func: '25', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' }
: { keyword: '30', func: '25', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' };
let completionTables = sharedTablesData;
if (
expectsTableName
&& currentDatabase
&& !sharedTablesData.some((t) => (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase())
) {
const lazyTables = await getLazyTablesByDB(currentDatabase);
if (lazyTables.length > 0) {
const seenTableKeys = new Set<string>();
completionTables = [...sharedTablesData, ...lazyTables].filter((table) => {
const key = `${String(table.dbName || '').toLowerCase()}.${String(table.tableName || '').toLowerCase()}`;
if (seenTableKeys.has(key)) return false;
seenTableKeys.add(key);
return true;
});
}
}
// 相关列提示:匹配 SQL 中引用的表FROM/JOIN 等)
// 权重最高,输入 WHERE 条件时优先显示
const relevantColumns = (expectsTableName ? [] : sharedAllColumnsData)
.filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (c.tableName || '').toLowerCase();
// 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users
const parsed = splitSchemaAndTable(c.tableName || '');
const pureIdent = (parsed.table || '').toLowerCase();
return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || '');
})
.map(c => {
// 当前库的表字段优先级更高
const isCurrentDb = (c.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
return {
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment),
documentation: buildCompletionDocumentation(c.comment),
range,
sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name,
};
});
// 表提示:当前库智能处理 schema.table 格式
// 1. 构建纯表名到 schema 列表的映射,检测同名表
const currentDbTables = completionTables.filter(t =>
(t.dbName || '').toLowerCase() === currentDatabase.toLowerCase()
);
const tableNameToSchemas = new Map<string, string[]>();
for (const t of currentDbTables) {
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = (parsed.table || t.tableName || '').toLowerCase();
const schemas = tableNameToSchemas.get(pureTable) || [];
schemas.push(parsed.schema || '');
tableNameToSchemas.set(pureTable, schemas);
}
const tableSuggestions = completionTables
.filter(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
if (!isCurrentDb) {
// 跨库:用 db.table 格式匹配
return includesWordPrefix(`${t.dbName}.${t.tableName}`)
|| includesWordPrefix(t.tableName || '')
|| includesWordPrefix(pureTable);
}
// 当前库:同时用完整名和纯表名匹配
return includesWordPrefix(t.tableName || '') || includesWordPrefix(pureTable);
})
.map(t => {
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const parsed = splitSchemaAndTable(t.tableName || '');
const pureTable = parsed.table || t.tableName || '';
if (!isCurrentDb) {
const label = `${t.dbName}.${t.tableName}`;
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText: label,
detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: sortGroups.tableOther + getPrefixMatchRank(`${t.dbName}.${t.tableName}`, t.tableName || '', pureTable) + t.tableName,
};
}
// 当前库:检查是否有跨 schema 同名表
const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || [];
const hasDuplicate = schemas.length > 1;
// 同名表存在于多个 schema → 显示 schema.table否则只显示纯表名
const label = hasDuplicate ? t.tableName : pureTable;
const insertText = hasDuplicate ? t.tableName : pureTable;
const schemaInfo = parsed.schema ? ` (${parsed.schema})` : '';
return {
label,
kind: monaco.languages.CompletionItemKind.Class,
insertText,
detail: appendCommentToDetail(`Table${schemaInfo}`, t.comment),
documentation: buildCompletionDocumentation(t.comment),
range,
sortText: sortGroups.tableCurrent + getPrefixMatchRank(t.tableName || '', pureTable) + pureTable,
};
});
// 数据库提示
const dbSuggestions = sharedVisibleDbs
.filter((db) => startsWithPrefix(db))
.map(db => ({
label: db,
kind: monaco.languages.CompletionItemKind.Module,
insertText: db,
detail: 'Database',
range,
sortText: sortGroups.db + db,
}));
// 关键字提示
const keywordSuggestions = dialectKeywords
.filter((k) => startsWithPrefix(k))
.map(k => ({
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
range,
sortText: sortGroups.keyword + k,
}));
// 内置函数提示
const funcSuggestions = dialectFunctions
.filter((f) => startsWithPrefix(f.name))
.map(f => ({
label: f.name,
kind: monaco.languages.CompletionItemKind.Function,
insertText: f.name + '($0)',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: f.detail,
range,
sortText: sortGroups.func + f.name,
}));
const suggestions = [
...relevantColumns, // FROM 表的列最优先
...tableSuggestions, // 表次之
...dbSuggestions, // 数据库
...funcSuggestions, // 内置函数
...keywordSuggestions // 关键字最后
];
return { suggestions };
}
}));
// 注册 / 斜杠命令 AI 快捷补全
const slashCmdDefs = [
{ cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' },
{ cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL' },
{ cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n{SQL}\n```', useSelection: true },
{ cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n{SQL}\n```', useSelection: true },
{ cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:' },
{ cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:' },
{ cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:' },
{ cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:' },
];
// 全局变量存储命令定义,供 onDidChangeModelContent 使用
(window as any).__gonaviSlashCmdDefs = slashCmdDefs;
sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['/'],
provideCompletionItems: (model: any, position: any) => {
const lineContent = model.getLineContent(position.lineNumber);
const textBefore = lineContent.substring(0, position.column - 1).trimStart();
if (!textBefore.startsWith('/')) {
return { suggestions: [] };
}
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: position.column - textBefore.length,
endColumn: position.column,
};
return {
suggestions: slashCmdDefs.map((c, i) => ({
label: `${c.cmd} ${c.label}`,
kind: monaco.languages.CompletionItemKind.Event,
detail: c.desc,
insertText: `__AI_${c.cmd.slice(1).toUpperCase()}__`,
range,
sortText: String(i).padStart(2, '0'),
})),
};
},
}));
// SQL snippet completion provider
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const prefix = word.word.toLowerCase();
if (!prefix) return { suggestions: [] };
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const allSnippets = useStore.getState().sqlSnippets;
const matched = allSnippets.filter(s =>
s.prefix.toLowerCase().startsWith(prefix) ||
s.name.toLowerCase().includes(prefix)
);
return {
suggestions: matched.map(s => ({
label: s.prefix,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: s.body,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: s.name,
documentation: s.description || s.body,
range,
sortText: '04' + s.prefix,
})),
};
},
});
} // end sqlCompletionRegistered guard
// 每个编辑器实例都注册内容变化监听(检测斜杠命令标记)
let _handlingSlash = false;
editor.onDidChangeModelContent((event: any) => {
if (_handlingSlash) return;
const hasSlashCommandMarker = Array.isArray(event?.changes)
&& event.changes.some((change: any) => /__AI_\w+__/.test(String(change?.text || '')));
if (!hasSlashCommandMarker) return;
const model = editor.getModel();
if (!model) return;
const content = model.getValue();
const markerMatch = content.match(/__AI_(\w+)__/);
if (!markerMatch) return;
const cmdKey = markerMatch[1].toLowerCase();
const defs = (window as any).__gonaviSlashCmdDefs || [];
const cmdDef = defs.find((c: any) => c.cmd === `/${cmdKey}`);
if (!cmdDef) return;
// 清除标记文本(带递归保护)
_handlingSlash = true;
const fullText = model.getValue();
const newText = fullText.replace(markerMatch[0], '').replace(/^\s*\n/, '');
model.setValue(newText);
_handlingSlash = false;
// 组装 prompt
const conn = connectionsRef.current.find(c => c.id === currentConnectionIdRef.current);
const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDbRef.current || '默认'}"】\n` : '';
let finalPrompt = ctxText + cmdDef.prompt;
if (cmdDef.useSelection) {
const sel = editor.getSelection();
const selText = sel ? model.getValueInRange(sel) : '';
finalPrompt = finalPrompt.replace('{SQL}', selText || getCurrentQuery());
}
// 打开 AI 面板并注入 prompt
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: finalPrompt } }));
}, store.aiPanelVisible ? 0 : 350);
});
};
const handleFormat = () => {
try {
const formatted = format(getCurrentQuery(), { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
const editor = editorRef.current;
const monaco = monacoRef.current;
const model = editor?.getModel?.();
if (editor && monaco && model) {
const currentValue = String(model.getValue?.() || '');
if (currentValue === formatted) {
return;
}
const fullRange = model.getFullModelRange?.()
|| new monaco.Range(1, 1, model.getLineCount?.() || 1, model.getLineMaxColumn?.(model.getLineCount?.() || 1) || 1);
editor.pushUndoStop?.();
editor.executeEdits?.('gonavi-format-sql', [{
range: fullRange,
text: formatted,
forceMoveMarkers: true,
}]);
editor.pushUndoStop?.();
const nextValue = editor.getValue?.();
applyQueryState(typeof nextValue === 'string' ? nextValue : formatted);
refreshObjectDecorations();
return;
}
syncQueryToEditor(formatted);
} catch (e) {
void message.error("格式化失败: SQL 语法可能有误");
}
};
const handleAIAction = (action: 'generate' | 'explain' | 'optimize' | 'schema') => {
const editor = editorRef.current;
const selection = editor?.getModel()?.getValueInRange(editor.getSelection()) || '';
const fullSQL = getCurrentQuery();
const conn = connections.find(c => c.id === currentConnectionId);
const ctxText = conn ? `【上下文环境:${conn.config?.type || '数据库'} "${conn.name}", 当前库选定为 "${currentDb || '默认'}"】\n` : '';
const prompts: Record<string, string> = {
generate: `${ctxText}请根据当前数据库表结构生成查询语句:`,
explain: `${ctxText}请解释以下 SQL 语句的执行逻辑:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``,
optimize: `${ctxText}请分析以下 SQL 语句的性能并给出优化建议:\n\`\`\`sql\n${selection || fullSQL}\n\`\`\``,
schema: `${ctxText}请针对当前数据库的表结构进行系统分析,并给出性能和设计上的优化建议。`,
};
const store = useStore.getState();
if (!store.aiPanelVisible) {
store.setAIPanelVisible(true);
}
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: prompts[action] } }));
};
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: 'snippet-settings',
label: '代码片段管理...',
onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')),
},
{
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 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 mergeResultSets = (previous: ResultSet[], next: ResultSet[], replaceAll: boolean): ResultSet[] => {
if (replaceAll || previous.length === 0) {
return next.map((result, index) => ({ ...result, key: `result-${index + 1}` }));
}
const merged = [...previous];
next.forEach((result) => {
const incomingKey = normalizeExecutedSqlKey(result.exportSql || result.sql);
const existingIndex = merged.findIndex((item) => normalizeExecutedSqlKey(item.exportSql || item.sql) === incomingKey);
if (existingIndex >= 0) {
merged[existingIndex] = { ...result, key: merged[existingIndex].key };
return;
}
merged.push({ ...result, key: `result-${resolveNextResultSetIndex(merged)}` });
});
return merged;
};
const resolveActiveResultKeyAfterMerge = (merged: ResultSet[], executed: ResultSet[]): string => {
const firstExecutedResult = executed[0];
if (!firstExecutedResult) {
return '';
}
const executedSqlKey = normalizeExecutedSqlKey(firstExecutedResult.exportSql || firstExecutedResult.sql);
return merged.find((item) => normalizeExecutedSqlKey(item.exportSql || item.sql) === executedSqlKey)?.key
|| firstExecutedResult.key
|| merged[0]?.key
|| '';
};
const resolveExecutableSQLAtEditorPosition = (model: any, sqlText: string, position: any): string => {
const normalizedPosition = normalizeEditorPosition(position);
if (!normalizedPosition) return '';
const cursorOffset = getNormalizedOffsetAtPosition(sqlText, normalizedPosition);
const resolved = resolveExecutableSql(sqlText, cursorOffset, '');
return resolved?.sql || '';
};
const getExecutableSQLAtCurrentCursor = (model: any, sqlText: string): string => {
const editor = editorRef.current;
const liveSelection = normalizeEditorPosition(editor?.getSelection?.());
if (liveSelection) {
return resolveExecutableSQLAtEditorPosition(model, sqlText, liveSelection);
}
const livePosition = normalizeEditorPosition(editor?.getPosition?.());
const cachedPosition = normalizeEditorPosition(lastEditorCursorPositionRef.current);
const candidates: Array<{ lineNumber: number; column: number }> = [];
if (cachedPosition) candidates.push(cachedPosition);
if (livePosition) candidates.push(livePosition);
const seen = new Set<string>();
for (const position of candidates) {
const key = `${position.lineNumber}:${position.column}`;
if (seen.has(key)) continue;
seen.add(key);
const sql = resolveExecutableSQLAtEditorPosition(model, sqlText, position);
if (sql.trim()) return sql;
}
const fallbackPosition = cachedPosition || livePosition;
return resolveExecutableSQLAtEditorPosition(model, sqlText, fallbackPosition);
};
const getExecutableSQL = (): string => {
const editor = editorRef.current;
const model = editor?.getModel?.();
const currentQuery = getCurrentQuery();
const selectedSQL = getSelectedSQL();
const selected = selectedSQL.trim();
if (!selected && resultSets.length > 0 && lastExecutedEditorQueryRef.current && currentQuery.startsWith(lastExecutedEditorQueryRef.current)) {
const appendedSQL = currentQuery.slice(lastExecutedEditorQueryRef.current.length);
if (appendedSQL.trim()) {
return appendedSQL;
}
}
if (!model || !editor) {
return selectedSQL || currentQuery;
}
if (selected) {
return selectedSQL;
}
return getExecutableSQLAtCurrentCursor(model, String(model.getValue?.() ?? currentQuery));
};
const captureEditorCursorPosition = (event?: React.MouseEvent<HTMLElement>) => {
event?.preventDefault();
const editor = editorRef.current;
const position = normalizeEditorPosition(editor?.getSelection?.()) || normalizeEditorPosition(editor?.getPosition?.());
if (position) {
lastEditorCursorPositionRef.current = position;
}
};
// 精准重查询单个结果集(提交事务 / 刷新按钮使用),不会重跑整个编辑器 SQL
const handleReloadResult = async (resultKey: string, sql: string) => {
if (!sql?.trim() || !currentDb) return;
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: "" }
};
try {
setLoading(true);
// 使用 DBQueryMulti 保持和首次查询一致的后端路径
let queryId: string;
try {
queryId = await GenerateQueryID();
} catch {
queryId = 'reload-' + Date.now();
}
const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, sql, queryId);
if (!res?.success) {
message.error('刷新失败: ' + (res?.message || '未知错误'));
return;
}
// 取第一个结果集(单条 SQL 只有一个结果集)
const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : [];
if (resultSetDataArray.length === 0) return;
const rsData = resultSetDataArray[0];
const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1
&& rsData.columns && rsData.columns.length === 1
&& rsData.columns[0] === 'affectedRows';
if (isAffectedResult) return; // 不应该出现,但保险起见
let rows = Array.isArray(rsData.rows) ? rsData.rows : [];
const maxRows = Number(queryOptions?.maxRows) || 0;
let truncated = false;
if (Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) {
truncated = true;
rows = rows.slice(0, maxRows);
}
const cols = (rsData.columns && rsData.columns.length > 0)
? rsData.columns
: (rows.length > 0 ? Object.keys(rows[0]) : []);
rows.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
// 只更新匹配的结果集的 rows 和 columns保留 tableName/pkColumns/readOnly 等元数据
setResultSets(prev => prev.map(rs =>
rs.key === resultKey
? { ...rs, rows, columns: cols, truncated }
: rs
));
} catch (err: any) {
message.error('刷新失败: ' + (err?.message || '未知错误'));
} finally {
setLoading(false);
}
};
const handleRun = async () => {
const currentQuery = getCurrentQuery();
if (!currentQuery.trim()) return;
const executableSQL = getExecutableSQL();
if (!executableSQL.trim()) {
message.info('没有可执行的 SQL。');
return;
}
if (!currentDb) {
message.error("请先选择数据库");
return;
}
// 如果已有查询在运行,先取消它
if (currentQueryIdRef.current) {
try {
await CancelQuery(currentQueryIdRef.current);
} catch (error) {
// 忽略取消错误,可能查询已完成
}
// 清除旧查询ID
clearQueryId();
}
const runSeq = ++runSeqRef.current;
setLoading(true);
setExecutionError('');
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: "" },
timeout: Math.max(Number(conn.config.timeout) || 30, 120),
};
try {
const rawSQL = executableSQL;
const rpcConfig = buildRpcConnectionConfig(config) as any;
const dbType = String(rpcConfig.type || 'mysql');
const driver = String((config as any).driver || '');
const normalizedDbType = String(resolveSqlDialect(dbType, driver, {
oceanBaseProtocol: (config as any).oceanBaseProtocol,
})).trim().toLowerCase();
const normalizedRawSQL = String(rawSQL || '').replace(//g, ';');
// MongoDB 仍走逐条执行的旧路径
const isMongoDB = normalizedDbType === 'mongodb';
if (isMongoDB) {
// MongoDB: 保持逐条执行
const splitInput = normalizedRawSQL
.replace(/^\s*\/\/.*$/gm, '')
.replace(/^\s*#.*$/gm, '');
const statements = splitSQLStatements(splitInput);
const didExecuteAppendedSql = resultSets.length > 0
&& lastExecutedEditorQueryRef.current
&& currentQuery.startsWith(lastExecutedEditorQueryRef.current)
&& normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(//g, ';').trim();
const didExecuteWholeEditor = areSqlStatementListsEqual(
splitSQLStatements(currentQuery.replace(//g, ';')),
statements,
);
if (statements.length === 0) {
message.info('没有可执行的 SQL。');
return;
}
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
let anyTruncated = false;
for (let idx = 0; idx < statements.length; idx++) {
const rawStatement = statements[idx];
let executedSql = rawStatement;
const shellConvert = convertMongoShellToJsonCommand(executedSql);
if (shellConvert.recognized) {
if (shellConvert.error) {
const prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
setExecutionError(prefix + shellConvert.error);
setResultSets([]);
setActiveResultKey('');
return;
}
if (shellConvert.command) {
executedSql = shellConvert.command;
}
}
if (wantsLimitProbe) {
const limitResult = applyMongoQueryAutoLimit(executedSql, maxRows);
if (limitResult.applied) {
executedSql = limitResult.command;
}
}
const startTime = Date.now();
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(buildRpcConnectionConfig(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 prefix = statements.length > 1 ? `${idx + 1} 条语句执行失败:` : '';
setExecutionError(prefix + res.message);
setResultSets([]);
setActiveResultKey('');
return;
}
if (Array.isArray(res.data)) {
let rows = (res.data as any[]) || [];
let truncated = false;
if (wantsLimitProbe && 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;
});
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
rows,
columns: cols,
pkColumns: [],
readOnly: true,
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
});
}
}
}
const shouldReplaceAllResults = didExecuteWholeEditor;
setResultSets(prev => {
const merged = mergeResultSets(prev, nextResultSets, shouldReplaceAllResults);
setActiveResultKey(resolveActiveResultKeyAfterMerge(merged, nextResultSets));
return merged;
});
if (didExecuteAppendedSql || didExecuteWholeEditor) {
lastExecutedEditorQueryRef.current = currentQuery;
}
if (statements.length > 1) {
message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
message.success('执行成功。');
}
} else {
// 非 MongoDB使用 DBQueryMulti 一次性执行多条 SQL后端返回多结果集
const sourceStatements = splitSQLStatements(normalizedRawSQL);
const didExecuteAppendedSql = resultSets.length > 0
&& lastExecutedEditorQueryRef.current
&& currentQuery.startsWith(lastExecutedEditorQueryRef.current)
&& normalizedRawSQL.trim() === currentQuery.slice(lastExecutedEditorQueryRef.current.length).replace(//g, ';').trim();
const didExecuteWholeEditor = areSqlStatementListsEqual(
splitSQLStatements(currentQuery.replace(//g, ';')),
sourceStatements,
);
if (sourceStatements.length === 0) {
message.info('没有可执行的 SQL。');
return;
}
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
const statementPlans: QueryStatementPlan[] = [];
for (const statement of sourceStatements) {
statementPlans.push(await resolveQueryLocatorPlan({
statement,
dbType: normalizedDbType,
currentDb,
config,
forceReadOnly: forceReadOnlyResult,
}));
}
// 自动给 SELECT 语句注入行数限制(防止大结果集卡死)
const maxRowsForLimit = Number(queryOptions?.maxRows) || 0;
let anyLimitApplied = false;
const executablePlans = statementPlans.map((plan) => {
if (!Number.isFinite(maxRowsForLimit) || maxRowsForLimit <= 0) return plan;
const result = applyQueryAutoLimit(plan.executedSql, normalizedDbType, maxRowsForLimit, driver);
if (result.applied) anyLimitApplied = true;
return { ...plan, executedSql: result.sql };
});
const fullSQL = executablePlans.map((plan) => plan.executedSql).join(';\n');
const startTime = Date.now();
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 DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, fullSQL, queryId);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query-multi`,
timestamp: Date.now(),
sql: fullSQL,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
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('');
if (currentQueryIdRef.current) {
clearQueryId();
}
return;
}
setExecutionError(res.message);
setResultSets([]);
setActiveResultKey('');
return;
}
// res.data 是 ResultSetData[] 数组
const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : [];
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
let anyTruncated = false;
for (let idx = 0; idx < resultSetDataArray.length; idx++) {
const rsData = resultSetDataArray[idx];
const plan = executablePlans[idx];
const originalSql = plan?.originalSql || '';
const executedSql = plan?.executedSql || originalSql;
// 检查是否为 affectedRows 类结果集
const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1
&& rsData.columns && rsData.columns.length === 1
&& rsData.columns[0] === 'affectedRows';
if (isAffectedResult) {
const affected = Number(rsData.rows[0]?.affectedRows);
const row = { affectedRows: Number.isFinite(affected) ? affected : 0 };
(row as any)[GONAVI_ROW_KEY] = 0;
nextResultSets.push({
key: `result-${idx + 1}`,
sql: executedSql,
exportSql: originalSql,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
readOnly: true
});
} else {
let rows = Array.isArray(rsData.rows) ? rsData.rows : [];
let truncated = false;
// 仅当前端自动注入了 LIMIT 时才做兜底截断;用户手写 LIMIT 时尊重原始结果
if (anyLimitApplied && Number.isFinite(maxRows) && maxRows > 0 && rows.length > maxRows) {
truncated = true;
anyTruncated = true;
rows = rows.slice(0, maxRows);
}
const cols = (rsData.columns && rsData.columns.length > 0)
? rsData.columns
: (rows.length > 0 ? Object.keys(rows[0]) : []);
rows.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
const tableRef = plan?.tableRef;
const editLocator = plan?.editLocator;
nextResultSets.push({
key: `result-${idx + 1}`,
sql: executedSql,
exportSql: originalSql,
rows,
columns: cols,
tableName: tableRef?.tableName,
pkColumns: plan?.pkColumns || [],
editLocator,
readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly,
truncated
});
}
}
const shouldReplaceAllResults = didExecuteWholeEditor;
setResultSets(prev => {
const merged = mergeResultSets(prev, nextResultSets, shouldReplaceAllResults);
setActiveResultKey(resolveActiveResultKeyAfterMerge(merged, nextResultSets));
return merged;
});
if (didExecuteAppendedSql || didExecuteWholeEditor) {
lastExecutedEditorQueryRef.current = currentQuery;
}
executablePlans.forEach((plan) => {
if (plan.warning) message.warning(plan.warning);
});
// 后端附带的提示信息(如数据源不支持原生多语句执行的回退提示)
if (res.message) {
message.info(res.message);
}
if (resultSetDataArray.length > 1) {
message.success(`已执行完成,生成 ${nextResultSets.length} 个结果集。`);
} else if (nextResultSets.length === 0) {
message.success('执行成功。');
}
}
} catch (e: any) {
message.error("Error executing query: " + e.message);
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: executableSQL || getExecutableSQL() || getCurrentQuery(),
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 handleSelectAllInEditor = (event: KeyboardEvent) => {
if (!isActive) {
return;
}
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== 'a') {
return;
}
const editor = editorRef.current;
if (!editor) {
return;
}
const targetNode = resolveEventTargetNode(event.target);
const editorHasFocus = !!editor.hasTextFocus?.();
const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode));
const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode));
if (!editorHasFocus && !inEditorPane) {
return;
}
if (!editorHasFocus && isEditableElement(event.target) && !inEditorPane) {
return;
}
if (!editorHasFocus && !inQueryEditor) {
return;
}
event.preventDefault();
event.stopPropagation();
editor.focus?.();
editor.trigger('keyboard', 'editor.action.selectAll', null);
};
window.addEventListener('keydown', handleSelectAllInEditor, true);
return () => {
window.removeEventListener('keydown', handleSelectAllInEditor, true);
};
}, [isActive]);
useEffect(() => {
const binding = runQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) {
return;
}
const handleRunShortcut = (event: KeyboardEvent) => {
if (!isActive) {
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, true);
return () => {
window.removeEventListener('keydown', handleRunShortcut, true);
};
}, [isActive, runQueryShortcutBinding, handleRun]);
// Re-register Monaco internal keybinding when runQuery shortcut changes
useEffect(() => {
if (runQueryActionRef.current) {
runQueryActionRef.current.dispose();
runQueryActionRef.current = null;
}
const editor = editorRef.current;
const monaco = monacoRef.current;
if (!editor || !monaco) return;
const binding = runQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
id: 'gonavi.runQuery',
label: 'GoNavi: 执行 SQL',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
},
});
}
return () => {
if (runQueryActionRef.current) {
runQueryActionRef.current.dispose();
runQueryActionRef.current = null;
}
};
}, [runQueryShortcutBinding]);
useEffect(() => {
if (selectCurrentStatementActionRef.current) {
selectCurrentStatementActionRef.current.dispose();
selectCurrentStatementActionRef.current = null;
}
const editor = editorRef.current;
const monaco = monacoRef.current;
if (!editor || !monaco) return;
const binding = selectCurrentStatementShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
if (keyBinding) {
selectCurrentStatementActionRef.current = editor.addAction({
id: 'gonavi.selectCurrentStatement',
label: 'GoNavi: 选择当前语句',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: handleSelectCurrentStatement,
});
}
return () => {
if (selectCurrentStatementActionRef.current) {
selectCurrentStatementActionRef.current.dispose();
selectCurrentStatementActionRef.current = null;
}
};
}, [selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]);
useEffect(() => {
if (saveQueryActionRef.current) {
saveQueryActionRef.current.dispose();
saveQueryActionRef.current = null;
}
const editor = editorRef.current;
const monaco = monacoRef.current;
if (!editor || !monaco) return;
const binding = saveQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
if (keyBinding) {
saveQueryActionRef.current = editor.addAction({
id: 'gonavi.saveQuery',
label: 'GoNavi: 保存查询',
keybindings: [keyBinding.keyMod | keyBinding.keyCode],
run: () => {
window.dispatchEvent(new CustomEvent('gonavi:save-active-query'));
},
});
}
return () => {
if (saveQueryActionRef.current) {
saveQueryActionRef.current.dispose();
saveQueryActionRef.current = null;
}
};
}, [saveQueryShortcutBinding]);
useEffect(() => {
const handleRunActiveQuery = () => {
if (!isActive) {
return;
}
void handleRun();
};
window.addEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener);
return () => {
window.removeEventListener('gonavi:run-active-query', handleRunActiveQuery as EventListener);
};
}, [isActive, handleRun]);
// 监听由 TabManager 分发的专用注入事件
useEffect(() => {
const handleInsertSql = (e: any) => {
if (e.detail?.tabId !== tab.id || !e.detail?.sql) return;
const { sql: sqlText, connectionId, dbName } = e.detail;
// 同步更新 ref防止异步 fetchDbs 竞态覆盖正确的 dbName
if (connectionId && connectionId !== currentConnectionId) {
if (dbName) {
currentDbRef.current = dbName;
setCurrentDb(dbName);
}
setCurrentConnectionId(connectionId);
} else if (dbName && dbName !== currentDb) {
currentDbRef.current = dbName;
setCurrentDb(dbName);
}
const editor = editorRef.current;
const monaco = monacoRef.current;
if (editor && monaco) {
const model = editor.getModel();
const existingContent = editor.getValue?.() || '';
// runImmediately 模式下,如果编辑器内容已是待注入的 SQLTabManager 创建时已传入),
// 跳过追加,直接选中全部内容并执行
if (e.detail.runImmediately && existingContent.trim() === sqlText.trim()) {
if (model) {
const lineCount = model.getLineCount();
const maxCol = model.getLineMaxColumn(lineCount);
editor.setSelection(new monaco.Range(1, 1, lineCount, maxCol));
editor.focus();
setTimeout(() => handleRun(), 500);
}
} else {
let position = editor.getPosition();
if (!position && model) {
const lineCount = model.getLineCount();
const maxCol = model.getLineMaxColumn(lineCount);
position = new monaco.Position(lineCount, maxCol);
}
if (position) {
const mText = (sqlText.endsWith('\n') ? sqlText : sqlText + '\n');
const startRange = new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column);
editor.executeEdits('ai-insert', [{
range: startRange,
text: (position.column > 1 ? '\n' : '') + mText,
forceMoveMarkers: true
}]);
const nextValue = editor.getValue?.();
if (typeof nextValue === 'string') {
applyQueryState(nextValue);
}
// 定位并滚动到可见区域
const targetLine = position.lineNumber + (position.column > 1 ? 1 : 0);
editor.revealLineInCenterIfOutsideViewport(targetLine);
editor.setPosition({ lineNumber: targetLine + mText.split('\n').length - 1, column: 1 });
editor.focus();
if (!e.detail.runImmediately) {
message.success('代码已在当前光标处成功插入');
}
if (e.detail.runImmediately) {
const endPosition = editor.getPosition();
editor.setSelection(new monaco.Range(
targetLine, 1,
endPosition.lineNumber, endPosition.column
));
// 🔧 延迟 500ms 等待连接/数据库切换的 setState 生效后再执行
setTimeout(() => handleRun(), 500);
}
}
}
} else {
applyQueryState(getCurrentQuery() ? `${getCurrentQuery()}\n${sqlText}` : sqlText);
message.success('代码已追加');
}
};
window.addEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener);
return () => window.removeEventListener('gonavi:insert-sql-to-tab', handleInsertSql as EventListener);
}, [tab.id, handleRun]);
const resolveDefaultQueryName = () => {
const rawTitle = String(tab.title || '').trim();
if (!rawTitle || rawTitle.startsWith('新建查询')) {
return '未命名查询';
}
return rawTitle;
};
const persistQuery = (payload: { id: string; name: string; createdAt?: number }) => {
const sql = getCurrentQuery();
const saved = {
id: payload.id,
name: payload.name,
sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
createdAt: payload.createdAt ?? Date.now(),
};
saveQuery(saved);
addTab({
...tab,
title: payload.name,
query: sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
savedQueryId: payload.id,
});
return saved;
};
const openSaveQueryModal = (mode: 'save' | 'rename') => {
setSaveModalMode(mode);
saveForm.setFieldsValue({ name: currentSavedQuery?.name || resolveDefaultQueryName() });
setIsSaveModalOpen(true);
};
const handleQuickSave = async () => {
const filePath = String(tab.filePath || '').trim();
if (filePath) {
const sql = getCurrentQuery();
try {
const res = await WriteSQLFile(filePath, sql);
if (!res.success) {
message.error('保存 SQL 文件失败: ' + (res.message || '未知错误'));
return;
}
addTab({
...tab,
query: sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
filePath,
savedQueryId: undefined,
});
setSQLFileTabDraft(tab.id, sql);
message.success('SQL 文件已保存!');
} catch (error) {
message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
}
return;
}
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const saveId = existed?.id || fallbackSavedId || '';
if (!saveId) {
openSaveQueryModal('save');
return;
}
const saveName = existed?.name || resolveDefaultQueryName();
persistQuery({ id: saveId, name: saveName, createdAt: existed?.createdAt });
message.success('查询已保存!');
};
const handleRenameQuery = () => {
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
if (!existed && !fallbackSavedId) {
message.warning('请先保存查询后再重命名');
openSaveQueryModal('save');
return;
}
openSaveQueryModal('rename');
};
const handleExportSQLFile = async () => {
try {
const res = await ExportSQLFile(currentSavedQuery?.name || resolveDefaultQueryName(), getCurrentQuery());
if (!res.success) {
if ((res.message || '') !== '已取消') {
message.error('导出 SQL 文件失败: ' + (res.message || '未知错误'));
}
return;
}
message.success('SQL 文件已导出!');
} catch (error) {
message.error('导出 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
}
};
const saveMoreMenuItems: MenuProps['items'] = [
{
key: 'rename-query',
label: '重命名查询',
disabled: !!tab.filePath,
onClick: handleRenameQuery,
},
{
key: 'export-sql-file',
label: '导出 SQL 文件',
onClick: () => void handleExportSQLFile(),
},
];
useEffect(() => {
const binding = saveQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) {
return;
}
const handleSaveShortcut = (event: KeyboardEvent) => {
if (!isActive) {
return;
}
if (!isShortcutMatch(event, binding.combo)) {
return;
}
const editor = editorRef.current;
const targetNode = resolveEventTargetNode(event.target);
const editorHasFocus = !!editor?.hasTextFocus?.();
const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode));
if (!editorHasFocus && !inQueryEditor) {
return;
}
event.preventDefault();
event.stopPropagation();
void handleQuickSave();
};
window.addEventListener('keydown', handleSaveShortcut, true);
return () => {
window.removeEventListener('keydown', handleSaveShortcut, true);
};
}, [isActive, saveQueryShortcutBinding, handleQuickSave]);
useEffect(() => {
const handleSaveActiveQuery = () => {
if (!isActive) {
return;
}
void handleQuickSave();
};
window.addEventListener('gonavi:save-active-query', handleSaveActiveQuery as EventListener);
return () => {
window.removeEventListener('gonavi:save-active-query', handleSaveActiveQuery as EventListener);
};
}, [isActive, handleQuickSave]);
const handleSave = async () => {
try {
const values = await saveForm.validateFields();
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const nextSavedId = existed?.id || fallbackSavedId || `saved-${Date.now()}`;
persistQuery({
id: nextSavedId,
name: String(values.name || '').trim() || '未命名查询',
createdAt: existed?.createdAt,
});
message.success(saveModalMode === 'rename' ? '查询已重命名!' : '查询已保存!');
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;
return next[idx]?.key || next[idx - 1]?.key || next[0]?.key || '';
});
return next;
});
};
const replaceResultSetsAfterMenuClose = (next: ResultSet[], preferredKey?: string) => {
setResultSets(next);
setActiveResultKey(prevActive => {
if (preferredKey && next.some(result => result.key === preferredKey)) return preferredKey;
if (prevActive && next.some(result => result.key === prevActive)) return prevActive;
return next[0]?.key || '';
});
};
const closeOtherResultTabs = (key: string) => {
const target = resultSets.find(result => result.key === key);
replaceResultSetsAfterMenuClose(target ? [target] : resultSets, key);
};
const closeResultTabsToLeft = (key: string) => {
const index = resultSets.findIndex(result => result.key === key);
if (index <= 0) return;
replaceResultSetsAfterMenuClose(resultSets.slice(index), key);
};
const closeResultTabsToRight = (key: string) => {
const index = resultSets.findIndex(result => result.key === key);
if (index < 0 || index >= resultSets.length - 1) return;
replaceResultSetsAfterMenuClose(resultSets.slice(0, index + 1), key);
};
const closeAllResultTabs = () => {
setResultSets([]);
setActiveResultKey('');
};
const buildResultTabMenuItems = (key: string, index: number): MenuProps['items'] => [
{
key: 'close-other',
label: '关闭其他页',
disabled: resultSets.length <= 1,
onClick: () => closeOtherResultTabs(key),
},
{
key: 'close-left',
label: '关闭左侧',
disabled: index <= 0,
onClick: () => closeResultTabsToLeft(key),
},
{
key: 'close-right',
label: '关闭右侧',
disabled: index >= resultSets.length - 1,
onClick: () => closeResultTabsToRight(key),
},
{ type: 'divider' },
{
key: 'close-all',
label: '关闭所有',
disabled: resultSets.length === 0,
onClick: closeAllResultTabs,
},
];
return (
<div ref={queryEditorRootRef} className={isV2Ui ? 'gn-v2-query-editor' : undefined} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<style>{`
.query-result-tabs {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.query-result-tabs .ant-tabs-nav {
flex: 0 0 auto;
margin: 0;
min-height: 38px;
}
.query-result-tabs .ant-tabs-nav-wrap {
flex: 0 1 auto;
min-width: 0;
}
.query-result-tabs .ant-tabs-nav-list {
align-items: center;
width: auto;
}
.query-result-tabs .ant-tabs-tab {
width: auto !important;
min-width: 0 !important;
max-width: 148px !important;
height: 30px !important;
min-height: 30px;
margin: 4px 6px 4px 0 !important;
padding: 0 9px !important;
border-radius: 999px !important;
border: 0.5px solid transparent !important;
border-right: 0.5px solid transparent !important;
align-items: center !important;
justify-content: center !important;
}
.query-result-tabs .ant-tabs-tab-btn {
width: auto !important;
height: 100%;
max-width: 100%;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 14px !important;
line-height: 1 !important;
}
.query-result-tabs .ant-tabs-tab.ant-tabs-tab-active::after {
display: none;
}
.query-result-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-result-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.query-result-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.query-result-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
}
.query-result-tabs .ant-tabs-tabpane-hidden {
display: none !important;
}
.query-result-tabs .ant-tabs-ink-bar {
transition: none !important;
}
.query-result-tab-label {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 126px;
height: 100%;
line-height: 1;
user-select: none;
-webkit-user-select: none;
}
.query-result-tab-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 700;
}
.query-result-tab-count {
flex: 0 0 auto;
min-width: 17px;
height: 17px;
padding: 0 5px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(148, 163, 184, 0.16);
color: inherit;
font-size: 11px;
font-weight: 700;
line-height: 17px;
}
.query-result-tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 999px;
color: #999;
cursor: pointer;
flex: 0 0 auto;
}
.query-result-tab-close:hover {
background: rgba(0, 0, 0, 0.06);
color: #666;
}
`}</style>
<div ref={editorPaneRef} className={isV2Ui ? 'gn-v2-query-editor-pane' : undefined}>
<div className={isV2Ui ? 'gn-v2-query-toolbar' : undefined} style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<div
className={isV2Ui ? 'gn-v2-query-toolbar-selects' : undefined}
style={{ display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}
>
<Select
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-connection-select' : undefined}
style={isV2Ui ? undefined : { width: 150 }}
placeholder="选择连接"
value={currentConnectionId}
onChange={(val) => {
setCurrentConnectionId(val);
setCurrentDb('');
}}
options={queryCapableConnections.map(c => ({ label: c.name, value: c.id }))}
showSearch
/>
<Select
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-database-select' : undefined}
style={isV2Ui ? undefined : { width: 200 }}
placeholder="选择数据库"
value={currentDb}
onChange={setCurrentDb}
options={dbList.map(db => ({ label: db, value: db }))}
showSearch
/>
<Tooltip title="最大返回行数(会对 SELECT 自动加 LIMIT防止大结果集卡死">
<Select
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-max-rows-select' : undefined}
style={isV2Ui ? undefined : { width: 170 }}
value={queryOptions?.maxRows ?? 5000}
onChange={(val) => setQueryOptions({ maxRows: Number(val) })}
options={[
{ label: '最大行数500', value: 500 },
{ label: '最大行数1000', value: 1000 },
{ label: '最大行数5000', value: 5000 },
{ label: '最大行数20000', value: 20000 },
{ label: '最大行数:不限', value: 0 },
]}
/>
</Tooltip>
</div>
<div
className={isV2Ui ? 'gn-v2-query-toolbar-actions' : undefined}
style={{ display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}
>
<Button.Group className={isV2Ui ? 'gn-v2-query-toolbar-action-group' : undefined}>
<Tooltip
title={
runQueryShortcutBinding.enabled && runQueryShortcutBinding.combo
? `运行(${getShortcutDisplayLabel(runQueryShortcutBinding.combo, activeShortcutPlatform)}`
: '运行'
}
>
<Button className={isV2Ui ? 'gn-v2-query-toolbar-run-action' : undefined} type="primary" icon={<PlayCircleOutlined />} onMouseDown={captureEditorCursorPosition} onClick={handleRun} loading={loading}>
</Button>
</Tooltip>
{loading && (
<Button type="primary" danger icon={<StopOutlined />} onClick={handleCancel}>
</Button>
)}
</Button.Group>
<Button.Group className={isV2Ui ? 'gn-v2-query-toolbar-action-group' : undefined}>
<Tooltip
title={
saveQueryShortcutBinding.enabled && saveQueryShortcutBinding.combo
? `保存(${getShortcutDisplayLabel(saveQueryShortcutBinding.combo, activeShortcutPlatform)}`
: '保存'
}
>
<Button icon={<SaveOutlined />} onClick={handleQuickSave}>
</Button>
</Tooltip>
<Dropdown menu={{ items: saveMoreMenuItems }} placement="bottomRight">
<Button></Button>
</Dropdown>
</Button.Group>
<Button.Group className={isV2Ui ? 'gn-v2-query-toolbar-action-group' : undefined}>
<Tooltip title="美化 SQL">
<Button icon={<FormatPainterOutlined />} onClick={handleFormat}></Button>
</Tooltip>
<Dropdown menu={{ items: formatSettingsMenu }} placement="bottomRight">
<Button className={isV2Ui ? 'gn-v2-query-toolbar-icon-action' : undefined} icon={<SettingOutlined />} />
</Dropdown>
</Button.Group>
<Dropdown menu={{ items: [
{ key: 'ai-generate', label: '生成 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('generate') },
{ key: 'ai-explain', label: '解释 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('explain') },
{ key: 'ai-optimize', label: '优化 SQL', icon: <RobotOutlined />, onClick: () => handleAIAction('optimize') },
{ type: 'divider' as const },
{ key: 'ai-schema', label: 'Schema 分析', icon: <RobotOutlined />, onClick: () => handleAIAction('schema') },
] }} placement="bottomRight">
<Button className={isV2Ui ? 'gn-v2-query-toolbar-ai-action' : undefined} icon={<RobotOutlined />} style={{ color: '#818cf8' }}>AI</Button>
</Dropdown>
</div>
</div>
<div ref={editorShellRef} className={isV2Ui ? 'gn-v2-query-monaco-shell' : undefined} style={{ height: editorHeight, minHeight: '100px' }}>
<Editor
height="100%"
gonaviTypography="code"
defaultLanguage="sql"
theme={darkMode ? "transparent-dark" : "transparent-light"}
defaultValue={query}
onChange={(val) => {
const nextValue = val || '';
syncQueryDraft(nextValue);
}}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: false },
automaticLayout: true,
fixedOverflowWidgets: true,
hover: {
enabled: true,
delay: QUERY_EDITOR_HOVER_DELAY_MS,
above: false,
},
scrollBeyondLastLine: false,
quickSuggestions: { other: true, comments: false, strings: false },
suggestOnTriggerCharacters: true,
}}
/>
</div>
<div
className={isV2Ui ? 'gn-v2-query-resizer' : undefined}
onMouseDown={handleMouseDown}
style={{
height: '5px',
cursor: 'row-resize',
background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)',
flexShrink: 0,
zIndex: 10
}}
title="拖动调整高度"
/>
</div>
<div className={isV2Ui ? 'gn-v2-query-results' : undefined} style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{resultSets.length > 0 ? (
<Tabs
className="query-result-tabs"
activeKey={activeResultKey || resultSets[0]?.key}
onChange={setActiveResultKey}
animated={false}
style={{ flex: 1, minHeight: 0 }}
items={resultSets.map((rs, idx) => ({
key: rs.key,
label: (
<Dropdown
menu={{ items: buildResultTabMenuItems(rs.key, idx) }}
trigger={['contextMenu']}
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
>
<div
className="query-result-tab-label"
onContextMenu={(event) => {
event.preventDefault();
}}
>
<Tooltip title={rs.sql}>
<span className="query-result-tab-text"> {idx + 1}</span>
</Tooltip>
{(() => {
const isAffected = rs.columns.length === 1 && rs.columns[0] === 'affectedRows';
if (isAffected) {
return <span className="query-result-tab-count"></span>;
}
if (!Array.isArray(rs.rows)) {
return null;
}
return <span className="query-result-tab-count">{rs.rows.length}</span>;
})()}
<Tooltip title="关闭结果">
<span
className="query-result-tab-close"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCloseResult(rs.key);
}}
>
<CloseOutlined style={{ fontSize: 12 }} />
</span>
</Tooltip>
</div>
</Dropdown>
),
children: (() => {
// affectedRows 类型结果集UPDATE/INSERT/DELETE简洁提示
const isAffectedResult = rs.columns.length === 1 && rs.columns[0] === 'affectedRows';
if (isAffectedResult) {
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
return (
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 8, color: '#666', userSelect: 'text',
}}>
<span style={{ fontSize: 36, color: '#52c41a' }}></span>
<span style={{ fontSize: 14, fontWeight: 500 }}></span>
<span style={{ fontSize: 13, color: '#999' }}>{affected}</span>
</div>
);
}
return (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={rs.rows}
columnNames={rs.columns}
loading={loading}
tableName={rs.tableName}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
onReload={() => handleReloadResult(rs.key, rs.sql)}
readOnly={rs.readOnly}
/>
</div>
);
})()
}))}
/>
) : executionError ? (
<div className={isV2Ui ? 'gn-v2-query-error' : undefined} style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<CloseOutlined />
<span></span>
</div>
<div className="custom-scrollbar" style={{ padding: 16, background: darkMode ? '#2d1a1a' : '#fff2f0', border: `1px solid ${darkMode ? '#5c2020' : '#ffccc7'}`, borderRadius: 6, color: darkMode ? '#ffa39e' : '#cf1322', fontFamily: 'var(--gn-font-mono)', fontSize: 'var(--gn-font-size-mono, 13px)', whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '40vh', overflow: 'auto' }}>
{executionError}
</div>
<div style={{ marginTop: 8 }}>
<Button
type="primary"
icon={<RobotOutlined />}
style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }}
onClick={() => {
const errSql = getCurrentQuery();
const prompt = `我在执行以下 SQL 时遇到了错误:\n\`\`\`sql\n${errSql}\n\`\`\`\n\n数据库报错信息如下\n\`\`\`text\n${executionError}\n\`\`\`\n\n请帮我分析错误原因并给出修改建议。`;
const store = useStore.getState();
const wasClosed = !store.aiPanelVisible;
if (wasClosed) store.setAIPanelVisible(true);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
}}
>
AI
</Button>
</div>
</div>
) : (
<div className={isV2Ui ? 'gn-v2-query-empty' : undefined} style={{ flex: 1, minHeight: 0 }}>
{isV2Ui && (
<div>
<strong> SQL</strong>
<span></span>
</div>
)}
</div>
)}
</div>
<Modal
title={saveModalMode === 'rename' ? '重命名查询' : '保存查询'}
open={isSaveModalOpen}
onOk={handleSave}
onCancel={() => setIsSaveModalOpen(false)}
okText={saveModalMode === 'rename' ? '重命名' : '保存'}
cancelText="取消"
>
<Form form={saveForm} layout="vertical">
<Form.Item name="name" label="查询名称" rules={[{ required: true, message: '请输入查询名称' }]}>
<Input placeholder="例如:查询所有用户" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
const setQueryEditorMouseCursor = (
editor: any,
cursor: '' | 'pointer',
) => {
const domNode = editor?.getDomNode?.();
if (domNode?.style) {
domNode.style.cursor = cursor;
}
};
export default React.memo(QueryEditor);