diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 7396e24..bed8925 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0464f9da25e9356e61652e638c99ffe \ No newline at end of file +0295a42fd931778d85157816d79d29e5 \ No newline at end of file diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 8f896b8..c81ddef 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -17,6 +17,7 @@ import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSql import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart } from '../utils/sql'; +import { resolveCurrentSqlStatementRange } from '../utils/sqlStatementSelection'; import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator'; @@ -637,6 +638,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const editorRef = useRef(null); const monacoRef = useRef(null); const runQueryActionRef = useRef(null); + const selectCurrentStatementActionRef = useRef(null); const lastExternalQueryRef = useRef(tab.query || ''); const dragRef = useRef<{ startY: number, startHeight: number } | null>(null); const queryEditorRootRef = useRef(null); @@ -721,6 +723,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc 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 || ''; setQuery(next); @@ -942,6 +969,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } + const selectStatementBinding = shortcutOptions.selectCurrentStatement; + 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, + }); + } + } + // HMR 重载时释放旧注册避免补全项重复 if (!sqlCompletionRegistered) { sqlCompletionRegistered = true; @@ -2226,6 +2268,37 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [shortcutOptions.runQuery]); + useEffect(() => { + if (selectCurrentStatementActionRef.current) { + selectCurrentStatementActionRef.current.dispose(); + selectCurrentStatementActionRef.current = null; + } + + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco) return; + + const binding = shortcutOptions.selectCurrentStatement; + 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; + } + }; + }, [shortcutOptions.selectCurrentStatement]); + useEffect(() => { const handleRunActiveQuery = () => { if (activeTabId !== tab.id) { diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts index 357bc40..fc696f5 100644 --- a/frontend/src/utils/shortcuts.test.ts +++ b/frontend/src/utils/shortcuts.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + DEFAULT_SHORTCUT_OPTIONS, findReservedConflict, findReservedConflicts, describeConflictContext, normalizeShortcutCombo, RESERVED_SHORTCUTS, comboToMonacoKeyBinding, + SHORTCUT_ACTION_META, } from './shortcuts'; import type { ConflictInfo } from './shortcuts'; @@ -113,6 +115,21 @@ describe('RESERVED_SHORTCUTS', () => { }); }); +// ─── shortcut defaults ─────────────────────────────────────────────── + +describe('shortcut defaults', () => { + it('registers select current statement as a query editor shortcut', () => { + expect(DEFAULT_SHORTCUT_OPTIONS.selectCurrentStatement).toEqual({ + combo: 'Ctrl+E', + enabled: true, + }); + expect(SHORTCUT_ACTION_META.selectCurrentStatement).toMatchObject({ + label: '选择当前语句', + scope: 'queryEditor', + }); + }); +}); + // ─── comboToMonacoKeyBinding ───────────────────────────────────────── describe('comboToMonacoKeyBinding', () => { diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index 1bc9d5f..67575bf 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -2,6 +2,7 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; export type ShortcutAction = | 'runQuery' + | 'selectCurrentStatement' | 'sendAIChatMessage' | 'focusSidebarSearch' | 'newQueryTab' @@ -22,7 +23,7 @@ export interface ShortcutActionMeta { description: string; allowInEditable?: boolean; allowWithoutModifier?: boolean; - scope?: 'global' | 'aiComposer'; + scope?: 'global' | 'aiComposer' | 'queryEditor'; requiredKey?: string; disallowShift?: boolean; platformOnly?: 'mac'; @@ -78,6 +79,7 @@ const KEY_ALIASES: Record = { export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'runQuery', + 'selectCurrentStatement', 'sendAIChatMessage', 'focusSidebarSearch', 'newQueryTab', @@ -92,6 +94,11 @@ export const SHORTCUT_ACTION_META: Record = label: '执行 SQL', description: '在当前查询页执行 SQL', }, + selectCurrentStatement: { + label: '选择当前语句', + description: '在查询编辑器中选中光标所在 SQL 语句', + scope: 'queryEditor', + }, sendAIChatMessage: { label: 'AI 聊天发送', description: '在 AI 输入框中发送当前消息,Shift+Enter 始终换行', @@ -132,6 +139,7 @@ export const SHORTCUT_ACTION_META: Record = export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = { runQuery: { combo: 'Ctrl+Shift+R', enabled: true }, + selectCurrentStatement: { combo: 'Ctrl+E', enabled: true }, sendAIChatMessage: { combo: 'Enter', enabled: true }, focusSidebarSearch: { combo: 'Ctrl+F', enabled: true }, newQueryTab: { combo: 'Ctrl+Shift+N', enabled: true }, @@ -487,4 +495,3 @@ export const comboToMonacoKeyBinding = ( if (keyCode == null) return null; return { keyMod, keyCode }; }; - diff --git a/frontend/src/utils/sqlStatementSelection.test.ts b/frontend/src/utils/sqlStatementSelection.test.ts new file mode 100644 index 0000000..5202323 --- /dev/null +++ b/frontend/src/utils/sqlStatementSelection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { findSqlStatementRanges, resolveCurrentSqlStatementRange } from './sqlStatementSelection'; + +describe('sqlStatementSelection', () => { + it('resolves the statement containing the cursor', () => { + const sql = 'select 1;\n\nselect 2 from users;\nselect 3'; + + expect(resolveCurrentSqlStatementRange(sql, sql.indexOf('1'))?.text).toBe('select 1'); + expect(resolveCurrentSqlStatementRange(sql, sql.indexOf('users'))?.text).toBe('select 2 from users'); + expect(resolveCurrentSqlStatementRange(sql, sql.indexOf('3'))?.text).toBe('select 3'); + }); + + it('ignores semicolons inside strings and comments', () => { + const sql = [ + "select ';' as semi;", + "-- comment ;", + "select 'a; b' as text;", + "select $$a; b$$ as body;", + ].join('\n'); + + const ranges = findSqlStatementRanges(sql).map((range) => range.text); + + expect(ranges).toEqual([ + "select ';' as semi", + "-- comment ;\nselect 'a; b' as text", + "select $$a; b$$ as body", + ]); + }); + + it('selects the next statement when the cursor is on whitespace before it', () => { + const sql = 'select 1;\n\n select 2;'; + const range = resolveCurrentSqlStatementRange(sql, sql.indexOf(' select 2')); + + expect(range?.text).toBe('select 2'); + }); + + it('returns null when there is no statement', () => { + expect(resolveCurrentSqlStatementRange(' \n\t ', 0)).toBeNull(); + }); +}); diff --git a/frontend/src/utils/sqlStatementSelection.ts b/frontend/src/utils/sqlStatementSelection.ts new file mode 100644 index 0000000..decf24b --- /dev/null +++ b/frontend/src/utils/sqlStatementSelection.ts @@ -0,0 +1,159 @@ +export interface SqlStatementRange { + start: number; + end: number; + text: string; +} + +const isWhitespace = (ch: string): boolean => ( + ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' +); + +const trimStatementRange = (sql: string, start: number, end: number): SqlStatementRange | null => { + let nextStart = Math.max(0, start); + let nextEnd = Math.min(sql.length, Math.max(start, end)); + + while (nextStart < nextEnd && isWhitespace(sql[nextStart])) { + nextStart++; + } + while (nextEnd > nextStart && isWhitespace(sql[nextEnd - 1])) { + nextEnd--; + } + + if (nextStart >= nextEnd) { + return null; + } + + return { + start: nextStart, + end: nextEnd, + text: sql.slice(nextStart, nextEnd), + }; +}; + +export const findSqlStatementRanges = (sql: string): SqlStatementRange[] => { + const text = String(sql || '').replace(/\r\n/g, '\n'); + const ranges: SqlStatementRange[] = []; + + let statementStart = 0; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + let dollarTag: string | null = null; + + const push = (end: number) => { + const range = trimStatementRange(text, statementStart, end); + if (range) { + ranges.push(range); + } + }; + + for (let index = 0; index < text.length; index++) { + const ch = text[index]; + const next = index + 1 < text.length ? text[index + 1] : ''; + const prev = index > 0 ? text[index - 1] : ''; + const next2 = index + 2 < text.length ? text[index + 2] : ''; + + if (dollarTag) { + if (text.startsWith(dollarTag, index)) { + index += dollarTag.length - 1; + dollarTag = null; + } + continue; + } + + if (inLineComment) { + if (ch === '\n') { + inLineComment = false; + } + continue; + } + + if (inBlockComment) { + if (ch === '*' && next === '/') { + index++; + inBlockComment = false; + } + continue; + } + + if (!inSingle && !inDouble && !inBacktick) { + if (ch === '/' && next === '*') { + index++; + inBlockComment = true; + continue; + } + if (ch === '#') { + inLineComment = true; + continue; + } + if (ch === '-' && next === '-' && (index === 0 || isWhitespace(prev)) && (next2 === '' || isWhitespace(next2))) { + index++; + inLineComment = true; + continue; + } + if (ch === '$') { + const match = text.slice(index).match(/^\$[A-Za-z0-9_]*\$/); + if (match?.[0]) { + dollarTag = match[0]; + index += dollarTag.length - 1; + continue; + } + } + } + + if (escaped) { + escaped = false; + continue; + } + + if ((inSingle || inDouble) && ch === '\\') { + escaped = true; + continue; + } + + if (!inDouble && !inBacktick && ch === "'") { + inSingle = !inSingle; + continue; + } + if (!inSingle && !inBacktick && ch === '"') { + inDouble = !inDouble; + continue; + } + if (!inSingle && !inDouble && ch === '`') { + inBacktick = !inBacktick; + continue; + } + + if (!inSingle && !inDouble && !inBacktick && (ch === ';' || ch === ';')) { + push(index); + statementStart = index + 1; + } + } + + push(text.length); + return ranges; +}; + +export const resolveCurrentSqlStatementRange = (sql: string, cursorOffset: number): SqlStatementRange | null => { + const text = String(sql || '').replace(/\r\n/g, '\n'); + const offset = Math.max(0, Math.min(text.length, Number.isFinite(cursorOffset) ? cursorOffset : 0)); + const ranges = findSqlStatementRanges(text); + if (ranges.length === 0) { + return null; + } + + const containingRange = ranges.find((range) => offset >= range.start && offset <= range.end); + if (containingRange) { + return containingRange; + } + + const nextRange = ranges.find((range) => offset < range.start); + if (nextRange) { + return nextRange; + } + + return ranges[ranges.length - 1]; +};