feat(query-editor): 新增选择当前语句快捷键

- 快捷键配置:新增选择当前语句动作,默认绑定 Ctrl+E
- 编辑器接入:在 Monaco 查询编辑器中注册选择当前语句 action
- 语句识别:新增 SQL 语句范围解析,支持按光标定位当前语句
- 兼容处理:忽略字符串、注释和 dollar quote 内的分号
- 测试覆盖:补充快捷键默认配置和语句选择解析单元测试
Refs #404
This commit is contained in:
Syngnat
2026-05-14 09:19:28 +08:00
parent f8abe60dc2
commit 6456658576
6 changed files with 300 additions and 3 deletions

View File

@@ -1 +1 @@
d0464f9da25e9356e61652e638c99ffe
0295a42fd931778d85157816d79d29e5

View File

@@ -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<any>(null);
const monacoRef = useRef<any>(null);
const runQueryActionRef = useRef<any>(null);
const selectCurrentStatementActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(tab.query || '');
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const queryEditorRootRef = useRef<HTMLDivElement | null>(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) {

View File

@@ -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', () => {

View File

@@ -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<string, string> = {
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'selectCurrentStatement',
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
@@ -92,6 +94,11 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
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<ShortcutAction, ShortcutActionMeta> =
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 };
};

View File

@@ -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();
});
});

View File

@@ -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];
};