mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-22 08:50:17 +08:00
✨ feat(query-editor): 新增选择当前语句快捷键
- 快捷键配置:新增选择当前语句动作,默认绑定 Ctrl+E - 编辑器接入:在 Monaco 查询编辑器中注册选择当前语句 action - 语句识别:新增 SQL 语句范围解析,支持按光标定位当前语句 - 兼容处理:忽略字符串、注释和 dollar quote 内的分号 - 测试覆盖:补充快捷键默认配置和语句选择解析单元测试 Refs #404
This commit is contained in:
@@ -1 +1 @@
|
||||
d0464f9da25e9356e61652e638c99ffe
|
||||
0295a42fd931778d85157816d79d29e5
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
41
frontend/src/utils/sqlStatementSelection.test.ts
Normal file
41
frontend/src/utils/sqlStatementSelection.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
159
frontend/src/utils/sqlStatementSelection.ts
Normal file
159
frontend/src/utils/sqlStatementSelection.ts
Normal 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];
|
||||
};
|
||||
Reference in New Issue
Block a user