diff --git a/README-zh_CN.md b/README-zh_CN.md
index 932f7609..7e58ffe8 100644
--- a/README-zh_CN.md
+++ b/README-zh_CN.md
@@ -205,6 +205,45 @@ npm install monaco-sql-languages
+## 运行语句按钮
+
+在每条 SQL 语句前显示 gutter 运行图标。创建编辑器后调用 `createRunStatementButton`,编辑器销毁时调用 `dispose()`。
+
+```typescript
+import {
+ LanguageIdEnum,
+ createRunStatementButton,
+ getStatementRangesByLanguage
+} from 'monaco-sql-languages';
+
+const controller = createRunStatementButton({
+ editor,
+ languageId: LanguageIdEnum.FLINK,
+ glyphMarginHoverMessage: '运行此语句',
+ preprocessCode: (code) => code,
+ onRun: ({ statement, editor, model }) => {
+ console.log(statement.text);
+ }
+});
+
+// controller.refresh(); // 需要时手动刷新图标
+// controller.dispose(); // 编辑器卸载时调用
+```
+
+**说明**
+
+- 建议显式传入 `languageId`,以便内置拆分器使用正确的 parser。
+- 自定义 `getStatementRanges` 只需提供准确的 `startLineNumber` 和 `text`,`executableLineNumber` 会自动归一化。
+- 编辑器只读时不显示图标,且忽略点击。
+- 若只需内置拆分逻辑,可单独使用 `getStatementRangesByLanguage(code, languageId, preprocessCode)`。
+- `preprocessCode` 必须与编辑器 model 保持行号对齐(不要增删行导致行号偏移);装饰是打在原始 model 上的。
+- 默认情况下,仅空白或仅分号的片段(如 `;;;`)不显示运行图标。
+- `executableLineNumber` 会跳过 leading 注释,但不解析字符串字面量;若 leading 字符串内含 `/*` 或 `--` 可能误判(实际场景较少见)。
+- 多条语句的 `executableLineNumber` 相同时会输出 console 警告,gutter 点击以最后一条为准。
+- 编辑器销毁时请调用 `controller.dispose()`(每个编辑器实例对应一个 controller)。
+
+
+
## 代码片段
我们为每种SQL语言内置了一部分代码片段, 帮助我们快速编写SQL。
diff --git a/README.md b/README.md
index 31f2ee58..0f433a5b 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,45 @@ npm install monaco-sql-languages
+## Run Statement Button
+
+Add a gutter run icon before each SQL statement. Call `createRunStatementButton` after creating the editor and dispose the controller when the editor is destroyed.
+
+```typescript
+import {
+ LanguageIdEnum,
+ createRunStatementButton,
+ getStatementRangesByLanguage
+} from 'monaco-sql-languages';
+
+const controller = createRunStatementButton({
+ editor,
+ languageId: LanguageIdEnum.FLINK,
+ glyphMarginHoverMessage: 'Run this statement',
+ preprocessCode: (code) => code,
+ onRun: ({ statement, editor, model }) => {
+ console.log(statement.text);
+ }
+});
+
+// controller.refresh(); // recompute icons manually when needed
+// controller.dispose(); // on editor unmount
+```
+
+**Notes**
+
+- Pass `languageId` explicitly so the built-in splitter uses the correct parser.
+- Custom `getStatementRanges` only needs accurate `startLineNumber` and `text`; `executableLineNumber` is normalized automatically.
+- Icons are hidden and clicks are ignored when the editor is read-only.
+- Use `getStatementRangesByLanguage(code, languageId, preprocessCode)` if you need the built-in splitter outside the button.
+- `preprocessCode` must keep the same line mapping as the editor model (no insert/delete that shifts lines); decorations are applied to the original model.
+- By default, blank or semicolon-only fragments (e.g. `;;;`) do not show a run icon.
+- `executableLineNumber` detection skips leading comments but does not parse string literals; a leading string that contains `/*` or `--` may be misidentified (rare in practice).
+- When multiple statements share the same `executableLineNumber`, a console warning is emitted and the last one wins for gutter clicks.
+- Call `controller.dispose()` when the editor is destroyed (one controller per editor instance).
+
+
+
## SQL Snippets
We provide some built-in SQL snippets for each SQL language, which helps us to write SQL quickly.
diff --git a/package.json b/package.json
index 33794750..a693f6fe 100644
--- a/package.json
+++ b/package.json
@@ -104,6 +104,6 @@
]
},
"dependencies": {
- "dt-sql-parser": "4.5.0"
+ "dt-sql-parser": "4.5.1"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a885130d..8701c328 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
dt-sql-parser:
- specifier: 4.5.0
- version: 4.5.0(antlr4ng-cli@1.0.7)
+ specifier: 4.5.1
+ version: 4.5.1(antlr4ng-cli@1.0.7)
devDependencies:
'@commitlint/cli':
specifier: ^17.7.2
@@ -723,8 +723,8 @@ packages:
resolution: {integrity: sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA==}
engines: {node: '>=6'}
- dt-sql-parser@4.5.0:
- resolution: {integrity: sha512-x12CX4vqeJKUZzCDjVBlG2DLQZ7ORnqVYYmnOFiz1IMBCOuamjHUeU05IGiRbRO+VqQSoN5RasiFJAjqzZ1WJw==}
+ dt-sql-parser@4.5.1:
+ resolution: {integrity: sha512-D1B4z2m6D1jkfruPo42x81jJ5uyuRH5lEx41K449DHvtVPSK+jYHWEUvgBixFaOBdP0ONyEnYpfzSUzRbgF1JA==}
engines: {node: '>=18'}
eastasianwidth@0.2.0:
@@ -2769,7 +2769,7 @@ snapshots:
find-up: 3.0.0
minimatch: 3.1.2
- dt-sql-parser@4.5.0(antlr4ng-cli@1.0.7):
+ dt-sql-parser@4.5.1(antlr4ng-cli@1.0.7):
dependencies:
antlr4-c3: 3.3.7(antlr4ng-cli@1.0.7)
antlr4ng: 2.0.11(antlr4ng-cli@1.0.7)
diff --git a/src/common/utils.ts b/src/common/utils.ts
index e635b7f5..a9634ddc 100644
--- a/src/common/utils.ts
+++ b/src/common/utils.ts
@@ -1,10 +1,16 @@
+export interface DebouncedFunction unknown> {
+ (...args: Parameters): unknown;
+ cancel: () => void;
+}
+
export function debounce unknown>(
func: T,
timeout: number,
immediate?: boolean
-): (...args: Parameters) => unknown {
+): DebouncedFunction {
let timer: NodeJS.Timeout | null = null;
- return (...args) => {
+
+ const debounced = (...args: Parameters) => {
if (timer) {
clearTimeout(timer);
}
@@ -18,4 +24,13 @@ export function debounce unknown>(
func?.(...args);
}, timeout);
};
+
+ debounced.cancel = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ return debounced;
}
diff --git a/src/languageFeatures.ts b/src/languageFeatures.ts
index d3877b37..cf8c74c0 100644
--- a/src/languageFeatures.ts
+++ b/src/languageFeatures.ts
@@ -145,6 +145,33 @@ function toDiagnostics(_resource: Uri, diag: ParseError): editor.IMarkerData {
};
}
+interface TokenizationTextModel {
+ tokenization?: {
+ tokenizeLinesAt?: (
+ lineNumber: number,
+ lines: string[]
+ ) => Array<{
+ findTokenIndexAtOffset: (offset: number) => number;
+ getStandardTokenType: (tokenIndex: number) => number;
+ }> | null;
+ };
+}
+
+function isPositionInComment(model: editor.IReadOnlyModel, position: Position): boolean {
+ const lineContent = model.getLineContent(position.lineNumber);
+ const tokenizationModel = model as editor.IReadOnlyModel & TokenizationTextModel;
+ // 在光标处追加哨兵字符,避免将刚结束的块注释误判为仍在注释中
+ const tokenizedLine = tokenizationModel.tokenization?.tokenizeLinesAt?.(position.lineNumber, [
+ `${lineContent.slice(0, position.column - 1)}x`
+ ])?.[0];
+ if (!tokenizedLine) {
+ return false;
+ }
+
+ const tokenIndex = tokenizedLine.findTokenIndexAtOffset(position.column - 1);
+ return tokenizedLine.getStandardTokenType(tokenIndex) === 1;
+}
+
export class CompletionAdapter
implements languages.CompletionItemProvider
{
@@ -165,6 +192,10 @@ export class CompletionAdapter
context: languages.CompletionContext,
_token: CancellationToken
): Promise {
+ if (isPositionInComment(model, position)) {
+ return Promise.resolve({ suggestions: [] });
+ }
+
const resource = model.uri;
return this._worker(resource)
.then((worker) => {
diff --git a/src/main.ts b/src/main.ts
index 900243c0..10b0d1bb 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,6 +5,7 @@ export * from './setupLanguageFeatures';
export * from './common/constants';
export * from './theme';
export * as snippets from './snippets';
+export * from './runStatementButton';
export { EntityContextType, StmtContextType } from 'dt-sql-parser';
diff --git a/src/runStatementButton/index.ts b/src/runStatementButton/index.ts
new file mode 100644
index 00000000..471d92d7
--- /dev/null
+++ b/src/runStatementButton/index.ts
@@ -0,0 +1,242 @@
+import { debounce } from '../common/utils';
+import { editor, IDisposable, Range } from '../fillers/monaco-editor-core';
+import { getStatementRangesByLanguage, normalizeStatementRanges } from './statementRanges';
+import { DEFAULT_RUN_GLYPH_CLASS_NAME, injectRunStatementButtonStyles } from './styles';
+import type {
+ RunStatementButtonController,
+ RunStatementButtonOptions,
+ RunStatementContext,
+ StatementRange
+} from './types';
+
+/** Hide run icons for blank or semicolon-only fragments (e.g. `;;;`). */
+function defaultShouldShowRunButton(statement: StatementRange): boolean {
+ return /[^\s;]/.test(statement.text);
+}
+
+function toHoverMessage(
+ message: string | { value: string } | undefined
+): { value: string } | undefined {
+ if (!message) {
+ return undefined;
+ }
+ return typeof message === 'string' ? { value: message } : message;
+}
+
+function isEditorReadOnly(codeEditor: editor.IStandaloneCodeEditor): boolean {
+ return codeEditor.getOption(editor.EditorOption.readOnly);
+}
+
+export function createRunStatementButton(
+ options: RunStatementButtonOptions
+): RunStatementButtonController {
+ const {
+ editor: codeEditor,
+ languageId,
+ onRun,
+ getStatementRanges,
+ shouldShowRunButton = defaultShouldShowRunButton,
+ glyphMarginClassName = DEFAULT_RUN_GLYPH_CLASS_NAME,
+ glyphMarginHoverMessage,
+ debounceMs = 200,
+ enableGlyphMargin = true,
+ preprocessCode,
+ injectDefaultStyles = true,
+ themeColors
+ } = options;
+
+ if (injectDefaultStyles) {
+ injectRunStatementButtonStyles(glyphMarginClassName, themeColors);
+ }
+
+ if (enableGlyphMargin) {
+ codeEditor.updateOptions({ glyphMargin: true });
+ }
+
+ const disposables: IDisposable[] = [];
+ let decorationCollection: editor.IEditorDecorationsCollection | null = null;
+ let lineToStatementMap = new Map();
+ let refreshRequestId = 0;
+ let disposed = false;
+
+ const resolveStatements = (code: string): StatementRange[] | Promise => {
+ const resolvedLanguageId = languageId ?? codeEditor.getModel()?.getLanguageId();
+ if (typeof getStatementRanges === 'function') {
+ return Promise.resolve(getStatementRanges(code, resolvedLanguageId)).then(
+ normalizeStatementRanges
+ );
+ }
+ return getStatementRangesByLanguage(code, resolvedLanguageId, preprocessCode);
+ };
+
+ const applyDecorations = (statements: StatementRange[]) => {
+ lineToStatementMap = new Map();
+ const decorations: editor.IModelDeltaDecoration[] = [];
+
+ if (isEditorReadOnly(codeEditor)) {
+ decorationCollection?.set([]);
+ return;
+ }
+
+ statements.forEach((statement) => {
+ if (!shouldShowRunButton(statement)) {
+ return;
+ }
+ const { executableLineNumber } = statement;
+ const existing = lineToStatementMap.get(executableLineNumber);
+ if (existing) {
+ console.warn(
+ '[monaco-sql-languages] Multiple run statements share executable line',
+ executableLineNumber,
+ { previous: existing.text, current: statement.text }
+ );
+ }
+ lineToStatementMap.set(executableLineNumber, statement);
+ decorations.push({
+ range: new Range(executableLineNumber, 1, executableLineNumber, 1),
+ options: {
+ glyphMarginClassName,
+ glyphMarginHoverMessage: toHoverMessage(glyphMarginHoverMessage)
+ }
+ });
+ });
+
+ if (decorationCollection) {
+ decorationCollection.set(decorations);
+ } else {
+ decorationCollection = codeEditor.createDecorationsCollection(decorations);
+ }
+ };
+
+ const refresh = () => {
+ if (disposed) {
+ return;
+ }
+
+ const model = codeEditor.getModel();
+ if (!model) {
+ lineToStatementMap.clear();
+ decorationCollection?.clear();
+ return;
+ }
+
+ const currentRequestId = ++refreshRequestId;
+
+ void Promise.resolve(resolveStatements(model.getValue()))
+ .then((statements) => {
+ if (disposed || currentRequestId !== refreshRequestId) {
+ return;
+ }
+ applyDecorations(statements);
+ })
+ .catch((error) => {
+ console.error(
+ '[monaco-sql-languages] Failed to resolve run statement ranges:',
+ error
+ );
+ });
+ };
+
+ const scheduleRefresh = debounce(() => {
+ refresh();
+ }, debounceMs);
+
+ const handleMouseDown = codeEditor.onMouseDown((event) => {
+ if (isEditorReadOnly(codeEditor)) {
+ return;
+ }
+ if (event.target.type !== editor.MouseTargetType.GUTTER_GLYPH_MARGIN) {
+ return;
+ }
+ const lineNumber = event.target.position?.lineNumber;
+ if (!lineNumber) {
+ return;
+ }
+
+ const statement = lineToStatementMap.get(lineNumber);
+ if (!statement) {
+ return;
+ }
+
+ event.event.preventDefault();
+ event.event.stopPropagation();
+
+ const model = codeEditor.getModel();
+ if (!model) {
+ return;
+ }
+
+ const context: RunStatementContext = {
+ statement,
+ editor: codeEditor,
+ model
+ };
+ void Promise.resolve()
+ .then(() => onRun(context))
+ .catch((error) => {
+ console.error('[monaco-sql-languages] Run statement handler failed:', error);
+ });
+ });
+
+ const handleModelChange = codeEditor.onDidChangeModel(() => {
+ scheduleRefresh();
+ });
+
+ const handleModelLanguageChange = codeEditor.onDidChangeModelLanguage(() => {
+ scheduleRefresh();
+ });
+
+ const handleContentChange = codeEditor.onDidChangeModelContent(() => {
+ scheduleRefresh();
+ });
+
+ const handleConfigurationChange = codeEditor.onDidChangeConfiguration((event) => {
+ if (event.hasChanged(editor.EditorOption.readOnly)) {
+ scheduleRefresh();
+ }
+ });
+
+ disposables.push(
+ handleMouseDown,
+ handleModelChange,
+ handleModelLanguageChange,
+ handleContentChange,
+ handleConfigurationChange
+ );
+
+ refresh();
+
+ return {
+ refresh,
+ dispose: () => {
+ disposed = true;
+ refreshRequestId += 1;
+ scheduleRefresh.cancel();
+ disposables.forEach((item) => item.dispose());
+ decorationCollection?.clear();
+ decorationCollection = null;
+ lineToStatementMap.clear();
+ }
+ };
+}
+
+export {
+ DEFAULT_RUN_GLYPH_CLASS_NAME,
+ DEFAULT_RUN_BUTTON_THEME_PALETTE,
+ injectRunStatementButtonStyles
+} from './styles';
+export {
+ getStatementRangesByLanguage,
+ normalizeStatementRange,
+ normalizeStatementRanges
+} from './statementRanges';
+export type {
+ RunStatementButtonOptions,
+ RunStatementButtonController,
+ RunStatementContext,
+ RunStatementButtonThemeColors,
+ RunStatementButtonThemePalette,
+ StatementRange,
+ GetStatementRanges,
+ ShouldShowRunButton
+} from './types';
diff --git a/src/runStatementButton/statementRanges.ts b/src/runStatementButton/statementRanges.ts
new file mode 100644
index 00000000..b911d2ea
--- /dev/null
+++ b/src/runStatementButton/statementRanges.ts
@@ -0,0 +1,125 @@
+/**
+ * Statement splitting for run-button gutter icons.
+ *
+ * Strategy:
+ * 1. Prefer dt-sql-parser `splitSQLByStatement` when languageId is known.
+ * 2. Fall back to semicolon split (respecting quotes).
+ * 3. Set `executableLineNumber` by skipping leading blank lines, line comments and block comments.
+ */
+import {
+ FlinkSQL,
+ GenericSQL,
+ HiveSQL,
+ ImpalaSQL,
+ MySQL,
+ PostgreSQL,
+ SparkSQL,
+ TrinoSQL
+} from 'dt-sql-parser';
+import type { TextSlice } from 'dt-sql-parser';
+import { BasicSQL } from 'dt-sql-parser/dist/parser/common/basicSQL';
+
+import { LanguageIdEnum } from '../common/constants';
+import type { PreprocessCode } from '../monaco.contribution';
+import {
+ buildStatementRange,
+ findFirstExecutableLine,
+ splitStatementsFallback
+} from './statementSplitUtils';
+import type { StatementRange } from './types';
+
+export { findFirstExecutableLine, splitStatementsFallback } from './statementSplitUtils';
+
+const parserMap: Record BasicSQL> = {
+ [LanguageIdEnum.FLINK]: () => new FlinkSQL(),
+ [LanguageIdEnum.HIVE]: () => new HiveSQL(),
+ [LanguageIdEnum.MYSQL]: () => new MySQL(),
+ [LanguageIdEnum.PG]: () => new PostgreSQL(),
+ [LanguageIdEnum.SPARK]: () => new SparkSQL(),
+ [LanguageIdEnum.TRINO]: () => new TrinoSQL(),
+ [LanguageIdEnum.IMPALA]: () => new ImpalaSQL(),
+ [LanguageIdEnum.GENERIC]: () => new GenericSQL()
+};
+
+const parserCache = new Map();
+
+function getOrCreateParser(languageId: string): BasicSQL | null {
+ const cached = parserCache.get(languageId);
+ if (cached) {
+ return cached;
+ }
+ const factory = parserMap[languageId];
+ if (!factory) {
+ return null;
+ }
+ const parser = factory();
+ parserCache.set(languageId, parser);
+ return parser;
+}
+
+/** Ensure `executableLineNumber` skips leading comments even for custom statement resolvers. */
+export function normalizeStatementRange(range: StatementRange): StatementRange {
+ const executableLineNumber = findFirstExecutableLine(range.text, range.startLineNumber);
+ if (executableLineNumber === range.executableLineNumber) {
+ return range;
+ }
+ return {
+ ...range,
+ executableLineNumber
+ };
+}
+
+export function normalizeStatementRanges(ranges: StatementRange[]): StatementRange[] {
+ return ranges.map(normalizeStatementRange);
+}
+
+function textSliceToStatementRange(slice: TextSlice): StatementRange {
+ return buildStatementRange(
+ slice.text,
+ slice.startLine,
+ slice.endLine,
+ slice.startColumn,
+ slice.endColumn
+ );
+}
+
+function splitStatementsByParser(source: string, languageId: string): StatementRange[] | null {
+ const parser = getOrCreateParser(languageId);
+ if (!parser) {
+ return null;
+ }
+
+ const slices = parser.splitSQLByStatement(source);
+ if (!slices?.length) {
+ return null;
+ }
+
+ return slices.map(textSliceToStatementRange).filter((item) => item.text.trim());
+}
+
+export function getStatementRangesByLanguage(
+ code: string,
+ languageId?: string,
+ preprocessCode?: PreprocessCode | null
+): StatementRange[] {
+ const source = typeof preprocessCode === 'function' ? preprocessCode(code) : code;
+ if (!source.trim()) {
+ return [];
+ }
+
+ if (languageId) {
+ try {
+ const parsedStatements = splitStatementsByParser(source, languageId);
+ if (parsedStatements?.length) {
+ return parsedStatements;
+ }
+ } catch (error) {
+ console.error(
+ '[monaco-sql-languages] Failed to split SQL statements by parser, fallback to semicolon split:',
+ error
+ );
+ }
+ }
+
+ return splitStatementsFallback(source);
+}
diff --git a/src/runStatementButton/statementSplitUtils.test.ts b/src/runStatementButton/statementSplitUtils.test.ts
new file mode 100644
index 00000000..257a128a
--- /dev/null
+++ b/src/runStatementButton/statementSplitUtils.test.ts
@@ -0,0 +1,72 @@
+import * as assert from 'assert';
+import { findFirstExecutableLine, splitStatementsFallback } from './statementSplitUtils';
+
+test('findFirstExecutableLine returns start line for plain SQL', () => {
+ assert.strictEqual(findFirstExecutableLine('SELECT 1;', 1), 1);
+ assert.strictEqual(findFirstExecutableLine('SELECT 1;', 5), 5);
+});
+
+test('findFirstExecutableLine skips leading blank lines', () => {
+ assert.strictEqual(findFirstExecutableLine('\n\nSELECT 1;', 1), 3);
+});
+
+test('findFirstExecutableLine skips leading line comments', () => {
+ const text = '-- comment\nSELECT 1;';
+ assert.strictEqual(findFirstExecutableLine(text, 1), 2);
+});
+
+test('findFirstExecutableLine skips leading block comments', () => {
+ const text = '/* block\ncomment */\nSELECT 1;';
+ assert.strictEqual(findFirstExecutableLine(text, 1), 3);
+});
+
+test('findFirstExecutableLine skips mixed leading whitespace and comments', () => {
+ const text = '\n -- line\n/* a */ SELECT 1;';
+ assert.strictEqual(findFirstExecutableLine(text, 10), 12);
+});
+
+test('findFirstExecutableLine returns startLineNumber when text is only comments', () => {
+ assert.strictEqual(findFirstExecutableLine('-- only comment', 3), 3);
+ assert.strictEqual(findFirstExecutableLine('/* only */', 1), 1);
+});
+
+test('splitStatementsFallback splits by semicolon', () => {
+ const ranges = splitStatementsFallback('SELECT 1; SELECT 2;');
+ assert.strictEqual(ranges.length, 2);
+ assert.strictEqual(ranges[0].text.trim(), 'SELECT 1;');
+ assert.strictEqual(ranges[1].text.trim(), 'SELECT 2;');
+ assert.strictEqual(ranges[0].executableLineNumber, 1);
+ assert.strictEqual(ranges[1].executableLineNumber, 1);
+});
+
+test('splitStatementsFallback keeps semicolon inside quotes', () => {
+ const ranges = splitStatementsFallback('SELECT \'a;b\'; SELECT "c;d";');
+ assert.strictEqual(ranges.length, 2);
+ assert.ok(ranges[0].text.includes("'a;b'"));
+ assert.ok(ranges[1].text.includes('"c;d"'));
+});
+
+test('splitStatementsFallback handles multiline statements and leading comments', () => {
+ const code = 'SELECT\n 1;\n-- comment\nSELECT 2;';
+ const ranges = splitStatementsFallback(code);
+ assert.strictEqual(ranges.length, 2);
+ assert.strictEqual(ranges[0].startLineNumber, 1);
+ assert.strictEqual(ranges[0].executableLineNumber, 1);
+ assert.strictEqual(ranges[1].executableLineNumber, 4);
+ assert.ok(ranges[1].text.includes('SELECT 2;'));
+});
+
+test('splitStatementsFallback keeps lone semicolons as statements', () => {
+ // Splitter still returns `;` segments; createRunStatementButton hides them by default.
+ const ranges = splitStatementsFallback('SELECT 1;;;SELECT 2;');
+ assert.strictEqual(ranges.length, 4);
+ assert.strictEqual(ranges[0].text.trim(), 'SELECT 1;');
+ assert.strictEqual(ranges[1].text, ';');
+ assert.strictEqual(ranges[2].text, ';');
+ assert.strictEqual(ranges[3].text.trim(), 'SELECT 2;');
+});
+
+test('splitStatementsFallback returns empty array for blank input', () => {
+ assert.deepStrictEqual(splitStatementsFallback(' \n\t'), []);
+ assert.deepStrictEqual(splitStatementsFallback(''), []);
+});
diff --git a/src/runStatementButton/statementSplitUtils.ts b/src/runStatementButton/statementSplitUtils.ts
new file mode 100644
index 00000000..66827be7
--- /dev/null
+++ b/src/runStatementButton/statementSplitUtils.ts
@@ -0,0 +1,126 @@
+/**
+ * Pure statement-split helpers (no parser deps) for run-button gutter placement.
+ */
+import type { StatementRange } from './types';
+
+/**
+ * Find the first non-comment, non-whitespace character and return its 1-based line number.
+ *
+ * Limitation: does not treat `--` / `/*` inside string literals as text; rare for leading gutter placement.
+ */
+export function findFirstExecutableLine(text: string, startLineNumber: number): number {
+ let index = 0;
+ let lineOffset = 0;
+ let inBlockComment = false;
+ const length = text.length;
+
+ while (index < length) {
+ const char = text[index];
+ const nextChar = text[index + 1];
+
+ if (inBlockComment) {
+ if (char === '*' && nextChar === '/') {
+ index += 2;
+ inBlockComment = false;
+ continue;
+ }
+ if (char === '\n') {
+ lineOffset++;
+ }
+ index++;
+ continue;
+ }
+
+ if (char === ' ' || char === '\t' || char === '\r') {
+ index++;
+ continue;
+ }
+
+ if (char === '\n') {
+ lineOffset++;
+ index++;
+ continue;
+ }
+
+ if (char === '-' && nextChar === '-') {
+ while (index < length && text[index] !== '\n') {
+ index++;
+ }
+ continue;
+ }
+
+ if (char === '/' && nextChar === '*') {
+ index += 2;
+ inBlockComment = true;
+ continue;
+ }
+
+ return startLineNumber + lineOffset;
+ }
+
+ return startLineNumber;
+}
+
+export function buildStatementRange(
+ text: string,
+ startLineNumber: number,
+ endLineNumber: number,
+ startColumn: number,
+ endColumn: number
+): StatementRange {
+ return {
+ executableLineNumber: findFirstExecutableLine(text, startLineNumber),
+ text,
+ startLineNumber,
+ endLineNumber,
+ startColumn,
+ endColumn
+ };
+}
+
+function getPositionFromOffset(code: string, offset: number): { line: number; column: number } {
+ const before = code.slice(0, offset);
+ const lines = before.split('\n');
+ return {
+ line: lines.length,
+ column: (lines[lines.length - 1]?.length ?? 0) + 1
+ };
+}
+
+/** Semicolon split fallback when parser split is unavailable; respects quotes. */
+export function splitStatementsFallback(code: string): StatementRange[] {
+ const statements: StatementRange[] = [];
+ let inSingleQuote = false;
+ let inDoubleQuote = false;
+ let lastSplit = 0;
+
+ const pushStatement = (start: number, end: number) => {
+ const text = code.slice(start, end);
+ if (!text.trim()) {
+ return;
+ }
+ const startPos = getPositionFromOffset(code, start);
+ const endPos = getPositionFromOffset(code, Math.max(start, end - 1));
+ statements.push(
+ buildStatementRange(text, startPos.line, endPos.line, startPos.column, endPos.column)
+ );
+ };
+
+ for (let i = 0; i < code.length; i++) {
+ const ch = code[i];
+ if (ch === "'" && !inDoubleQuote) {
+ inSingleQuote = !inSingleQuote;
+ } else if (ch === '"' && !inSingleQuote) {
+ inDoubleQuote = !inDoubleQuote;
+ } else if (ch === ';' && !inSingleQuote && !inDoubleQuote) {
+ pushStatement(lastSplit, i + 1);
+ lastSplit = i + 1;
+ }
+ }
+
+ if (lastSplit < code.length) {
+ pushStatement(lastSplit, code.length);
+ }
+
+ return statements;
+}
diff --git a/src/runStatementButton/styles.ts b/src/runStatementButton/styles.ts
new file mode 100644
index 00000000..dd86ce65
--- /dev/null
+++ b/src/runStatementButton/styles.ts
@@ -0,0 +1,94 @@
+import type { RunStatementButtonThemeColors, RunStatementButtonThemePalette } from './types';
+
+const STYLE_ELEMENT_ID_PREFIX = 'monaco-sql-languages-run-statement-button-styles';
+
+export const DEFAULT_RUN_GLYPH_CLASS_NAME = 'mssql-run-statement-glyph';
+
+/** Default palette aligned with VS Code run/debug accent colors. */
+export const DEFAULT_RUN_BUTTON_THEME_PALETTE: Record<
+ 'dark' | 'light' | 'hc',
+ RunStatementButtonThemeColors
+> = {
+ dark: { normal: '#73c991', hover: '#89d19d' },
+ light: { normal: '#16825d', hover: '#107c10' },
+ hc: { normal: '#1aebff', hover: '#4df2ff' }
+};
+
+function getStyleElementId(className: string): string {
+ return `${STYLE_ELEMENT_ID_PREFIX}-${className.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
+}
+
+function buildTriangleRules(
+ className: string,
+ selector: string,
+ colors: RunStatementButtonThemeColors
+): string {
+ return `
+${selector} .${className}::before {
+ border-left-color: ${colors.normal};
+}
+${selector} .${className}:hover::before {
+ border-left-color: ${colors.hover};
+}
+`;
+}
+
+function getStyleContent(
+ className: string,
+ palette: Record<'dark' | 'light' | 'hc', RunStatementButtonThemeColors>
+): string {
+ return `
+.monaco-editor .${className} {
+ cursor: pointer;
+ position: relative;
+ opacity: 1 !important;
+ background: none !important;
+ background-image: none !important;
+}
+.monaco-editor .${className}::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ margin-left: -3px;
+ transform: translate(-50%, -50%);
+ border-style: solid;
+ border-width: 5px 0 5px 9px;
+ border-color: transparent;
+ pointer-events: none;
+ transition: border-color 0.15s ease;
+}
+${buildTriangleRules(className, '.monaco-editor.vs-dark', palette.dark)}
+${buildTriangleRules(className, '.monaco-editor.hc-black', palette.hc)}
+${buildTriangleRules(className, '.monaco-editor.vs', palette.light)}
+${buildTriangleRules(className, '.monaco-editor.hc-light', palette.hc)}
+`;
+}
+
+export function injectRunStatementButtonStyles(
+ className = DEFAULT_RUN_GLYPH_CLASS_NAME,
+ palette?: RunStatementButtonThemePalette
+): void {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ const resolvedPalette = {
+ dark: palette?.dark ?? DEFAULT_RUN_BUTTON_THEME_PALETTE.dark,
+ light: palette?.light ?? DEFAULT_RUN_BUTTON_THEME_PALETTE.light,
+ hc: palette?.hc ?? DEFAULT_RUN_BUTTON_THEME_PALETTE.hc
+ };
+
+ const styleElementId = getStyleElementId(className);
+ const styleContent = getStyleContent(className, resolvedPalette);
+ const existing = document.getElementById(styleElementId);
+ if (existing) {
+ existing.textContent = styleContent;
+ return;
+ }
+
+ const style = document.createElement('style');
+ style.id = styleElementId;
+ style.textContent = styleContent;
+ document.head.appendChild(style);
+}
diff --git a/src/runStatementButton/types.ts b/src/runStatementButton/types.ts
new file mode 100644
index 00000000..b5e61ccc
--- /dev/null
+++ b/src/runStatementButton/types.ts
@@ -0,0 +1,86 @@
+import type { PreprocessCode } from '../monaco.contribution';
+import type { editor, IDisposable } from '../fillers/monaco-editor-core';
+
+export interface RunStatementButtonThemeColors {
+ normal: string;
+ hover: string;
+}
+
+export interface RunStatementButtonThemePalette {
+ dark?: RunStatementButtonThemeColors;
+ light?: RunStatementButtonThemeColors;
+ hc?: RunStatementButtonThemeColors;
+}
+
+export interface StatementRange {
+ /**
+ * Gutter icon line (1-based). Skips leading blank lines and comments — not necessarily `startLineNumber`.
+ * Built-in and normalized custom resolvers compute this automatically.
+ */
+ executableLineNumber: number;
+ /** Full statement text passed to `onRun`. */
+ text: string;
+ /** Statement block start line (1-based, may include leading comments). */
+ startLineNumber: number;
+ endLineNumber: number;
+ startColumn: number;
+ endColumn: number;
+}
+
+export interface RunStatementContext {
+ statement: StatementRange;
+ editor: editor.IStandaloneCodeEditor;
+ model: editor.ITextModel;
+}
+
+/**
+ * Custom statement resolver. When provided, it fully replaces the built-in splitter.
+ * Return ranges with accurate `startLineNumber` / `text`; `executableLineNumber` is normalized automatically.
+ */
+export type GetStatementRanges = (
+ code: string,
+ languageId?: string
+) => StatementRange[] | Promise;
+
+export type ShouldShowRunButton = (statement: StatementRange) => boolean;
+
+export interface RunStatementButtonOptions {
+ editor: editor.IStandaloneCodeEditor;
+ /**
+ * SQL language id, e.g. {@link LanguageIdEnum.FLINK}.
+ * Used by the built-in statement splitter when `getStatementRanges` is not provided.
+ */
+ languageId?: string;
+ /** Called when the run icon is clicked. Async errors are logged to the console. */
+ onRun: (context: RunStatementContext) => void | Promise;
+ /** Custom statement resolver. See {@link GetStatementRanges}. */
+ getStatementRanges?: GetStatementRanges;
+ /**
+ * Whether to show the run icon for a statement.
+ * Defaults to statements that contain at least one non-whitespace, non-semicolon character.
+ */
+ shouldShowRunButton?: ShouldShowRunButton;
+ /** CSS class applied to the glyph margin icon. */
+ glyphMarginClassName?: string;
+ /** Hover message shown on the run icon. No hover text when omitted. */
+ glyphMarginHoverMessage?: string | { value: string };
+ /** Debounce duration for content updates in milliseconds. Defaults to 200. */
+ debounceMs?: number;
+ /** Whether to enable glyph margin on the editor. Defaults to true. */
+ enableGlyphMargin?: boolean;
+ /**
+ * Preprocess code before resolving statements.
+ * Must preserve line-number alignment with the editor model (do not insert/delete lines);
+ * otherwise gutter icons may land on the wrong lines.
+ */
+ preprocessCode?: PreprocessCode | null;
+ /** Inject default run icon styles into the document. Defaults to true. */
+ injectDefaultStyles?: boolean;
+ /** Custom icon colors per editor theme base (vs-dark / vs / hc). */
+ themeColors?: RunStatementButtonThemePalette;
+}
+
+export interface RunStatementButtonController extends IDisposable {
+ /** Recompute statement ranges and refresh gutter icons. */
+ refresh(): void;
+}
diff --git a/src/test/languageFeatures.test.ts b/src/test/languageFeatures.test.ts
new file mode 100644
index 00000000..565e6c0c
--- /dev/null
+++ b/src/test/languageFeatures.test.ts
@@ -0,0 +1,229 @@
+import * as assert from 'assert';
+
+import { BaseSQLWorker } from '../baseSQLWorker';
+import { LanguageIdEnum } from '../common/constants';
+import {
+ CancellationTokenSource,
+ editor,
+ languages,
+ Position
+} from '../fillers/monaco-editor-core';
+import { CompletionAdapter, WorkerAccessor } from '../languageFeatures';
+import { language as flinkLanguage } from '../languages/flink/flink';
+import { language as genericLanguage } from '../languages/generic/generic';
+import { language as hiveLanguage } from '../languages/hive/hive';
+import { language as impalaLanguage } from '../languages/impala/impala';
+import { language as mysqlLanguage } from '../languages/mysql/mysql';
+import { language as pgsqlLanguage } from '../languages/pgsql/pgsql';
+import { language as sparkLanguage } from '../languages/spark/spark';
+import { language as trinoLanguage } from '../languages/trino/trino';
+import { LanguageServiceDefaultsImpl, modeConfigurationDefault } from '../monaco.contribution';
+
+const SQL_DIALECTS = [
+ { name: LanguageIdEnum.FLINK, language: flinkLanguage },
+ { name: LanguageIdEnum.HIVE, language: hiveLanguage },
+ { name: LanguageIdEnum.MYSQL, language: mysqlLanguage },
+ { name: LanguageIdEnum.PG, language: pgsqlLanguage },
+ { name: LanguageIdEnum.SPARK, language: sparkLanguage },
+ { name: LanguageIdEnum.TRINO, language: trinoLanguage },
+ { name: LanguageIdEnum.IMPALA, language: impalaLanguage },
+ { name: LanguageIdEnum.GENERIC, language: genericLanguage }
+].map((dialect) => ({
+ ...dialect,
+ languageId: `${dialect.name}-completion-test`
+}));
+const MYSQL_DIALECT = SQL_DIALECTS.find(({ name }) => name === LanguageIdEnum.MYSQL)!;
+
+SQL_DIALECTS.forEach((dialect) => {
+ languages.register({ id: dialect.languageId });
+ languages.setMonarchTokensProvider(dialect.languageId, dialect.language);
+});
+
+interface CompletionResult {
+ suggestions: languages.CompletionItem[];
+ workerCallCount: number;
+}
+
+function getEndPosition(value: string): Position {
+ const lines = value.split('\n');
+ return new Position(lines.length, lines[lines.length - 1].length + 1);
+}
+
+async function provideCompletionItems(
+ languageId: string,
+ value: string,
+ position: Position = getEndPosition(value)
+): Promise {
+ const model = editor.createModel(value, languageId);
+ const cancellationTokenSource = new CancellationTokenSource();
+ let workerCallCount = 0;
+ const worker: WorkerAccessor = async () => {
+ workerCallCount++;
+ return {
+ doCompletionWithEntities: async () => ({
+ suggestions: {
+ syntax: [],
+ keywords: ['SELECT']
+ },
+ allEntities: null,
+ context: null
+ })
+ } as unknown as BaseSQLWorker;
+ };
+ const defaults = new LanguageServiceDefaultsImpl(languageId, modeConfigurationDefault);
+ const adapter = new CompletionAdapter(worker, defaults);
+
+ try {
+ const completionList = await adapter.provideCompletionItems(
+ model,
+ position,
+ { triggerKind: languages.CompletionTriggerKind.Invoke },
+ cancellationTokenSource.token
+ );
+
+ return {
+ suggestions: completionList.suggestions,
+ workerCallCount
+ };
+ } finally {
+ cancellationTokenSource.dispose();
+ model.dispose();
+ }
+}
+
+SQL_DIALECTS.forEach((dialect) => {
+ test(`does not provide ${dialect.name} completion items inside a line comment`, async () => {
+ const result = await provideCompletionItems(dialect.languageId, 'SELECT 1 -- comment');
+
+ assert.deepStrictEqual(result.suggestions, []);
+ assert.strictEqual(result.workerCallCount, 0);
+ });
+
+ test(`does not provide ${dialect.name} completion items inside a multiline block comment`, async () => {
+ const result = await provideCompletionItems(
+ dialect.languageId,
+ 'SELECT /* comment\nstill comment */',
+ new Position(2, 6)
+ );
+
+ assert.deepStrictEqual(result.suggestions, []);
+ assert.strictEqual(result.workerCallCount, 0);
+ });
+});
+
+test('does not provide completion items after a line comment marker', async () => {
+ const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '--');
+
+ assert.deepStrictEqual(result.suggestions, []);
+ assert.strictEqual(result.workerCallCount, 0);
+});
+
+test('does not provide completion items inside a MySQL hash comment', async () => {
+ const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '# comment');
+
+ assert.deepStrictEqual(result.suggestions, []);
+ assert.strictEqual(result.workerCallCount, 0);
+});
+
+test('does not provide completion items inside a block comment', async () => {
+ const result = await provideCompletionItems(
+ MYSQL_DIALECT.languageId,
+ '/* comment */',
+ new Position(1, 4)
+ );
+
+ assert.deepStrictEqual(result.suggestions, []);
+ assert.strictEqual(result.workerCallCount, 0);
+});
+
+test('provides completion items after a closed block comment', async () => {
+ const result = await provideCompletionItems(MYSQL_DIALECT.languageId, 'SELECT /* comment */');
+
+ assert.deepStrictEqual(
+ result.suggestions.map((item) => item.label),
+ ['SELECT']
+ );
+ assert.strictEqual(result.workerCallCount, 1);
+});
+
+test('does not treat comment markers inside strings as comments', async () => {
+ const result = await provideCompletionItems(MYSQL_DIALECT.languageId, "SELECT '--'");
+
+ assert.deepStrictEqual(
+ result.suggestions.map((item) => item.label),
+ ['SELECT']
+ );
+ assert.strictEqual(result.workerCallCount, 1);
+});
+
+test('does not treat a single minus sign as a comment', async () => {
+ const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '-');
+
+ assert.deepStrictEqual(
+ result.suggestions.map((item) => item.label),
+ ['SELECT']
+ );
+ assert.strictEqual(result.workerCallCount, 1);
+});
+
+test('checks comment state without reading the whole document prefix', async () => {
+ const value = 'SELECT 1;\n-- comment';
+ const model = editor.createModel(value, MYSQL_DIALECT.languageId);
+ const position = getEndPosition(value);
+ const originalGetValueInRange = model.getValueInRange.bind(model);
+ const cancellationTokenSource = new CancellationTokenSource();
+ const defaults = new LanguageServiceDefaultsImpl(
+ MYSQL_DIALECT.languageId,
+ modeConfigurationDefault
+ );
+ const adapter = new CompletionAdapter(
+ async () =>
+ ({
+ doCompletionWithEntities: async () => ({
+ suggestions: {
+ syntax: [],
+ keywords: ['SELECT']
+ },
+ allEntities: null,
+ context: null
+ })
+ }) as unknown as BaseSQLWorker,
+ defaults
+ );
+
+ model.getValueInRange = ((range, eol) => {
+ if (
+ range.startLineNumber === 1 &&
+ range.startColumn === 1 &&
+ (range.endLineNumber > 1 || range.endColumn > 1)
+ ) {
+ throw new Error('comment detection should not read the whole document prefix');
+ }
+
+ return originalGetValueInRange(range, eol);
+ }) as typeof model.getValueInRange;
+
+ try {
+ const completionList = await adapter.provideCompletionItems(
+ model,
+ position,
+ { triggerKind: languages.CompletionTriggerKind.Invoke },
+ cancellationTokenSource.token
+ );
+
+ assert.deepStrictEqual(completionList.suggestions, []);
+ } finally {
+ cancellationTokenSource.dispose();
+ model.dispose();
+ }
+});
+
+test('keeps the existing completion flow for regular SQL', async () => {
+ const result = await provideCompletionItems(MYSQL_DIALECT.languageId, 'SELECT ');
+
+ assert.deepStrictEqual(
+ result.suggestions.map((item) => item.label),
+ ['SELECT']
+ );
+ assert.strictEqual(result.workerCallCount, 1);
+});
diff --git a/test/all.js b/test/all.js
index 7be7010a..bca4bd49 100644
--- a/test/all.js
+++ b/test/all.js
@@ -103,20 +103,19 @@ requirejs(
function () {
let files;
try {
- files = [
- ...glob.sync('out/amd/languages/*/*.test.js', {
+ files = glob.sync(
+ [
+ 'out/amd/languages/*/*.test.js',
+ 'out/amd/runStatementButton/*.test.js',
+ 'out/amd/test/*.test.js',
+ 'out/amd/format.test.js',
+ 'out/amd/formatBridge.test.js'
+ ],
+ {
cwd: path.dirname(__dirname),
dot: true
- }),
- ...glob.sync('out/amd/format.test.js', {
- cwd: path.dirname(__dirname),
- dot: true
- }),
- ...glob.sync('out/amd/formatBridge.test.js', {
- cwd: path.dirname(__dirname),
- dot: true
- })
- ];
+ }
+ );
} catch (err) {
console.log(err);
return;
diff --git a/test/setup.js b/test/setup.js
index 6d6a4ea7..2bdda08b 100644
--- a/test/setup.js
+++ b/test/setup.js
@@ -29,6 +29,17 @@ define('vs/nls', [], {
}
});
+define('dt-sql-parser', [], {
+ EntityContextType: {
+ TABLE: 'table',
+ TABLE_CREATE: 'tableCreate'
+ }
+});
+
+define('monaco-editor', ['vs/editor/editor.main'], function (api) {
+ return api.m || api;
+});
+
define(['vs/editor/editor.main'], function (api) {
// Monaco Editor 0.54.0+ exports as api.m instead of api directly
const monaco = api.m || api;
diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml
index a78a0462..f590edb3 100644
--- a/website/pnpm-lock.yaml
+++ b/website/pnpm-lock.yaml
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
+overrides:
+ monaco-editor: 0.54.0
+
importers:
.:
@@ -627,42 +630,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -1845,28 +1842,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -2029,9 +2022,6 @@ packages:
monaco-editor-nls@3.1.0:
resolution: {integrity: sha512-GdzgKRAiwXlGI/ude/HbzP8ukml28yUoZ7rx/xsSsmYf9W13jTyWjXjoMK/+pjL6JorAThH2WkCYaDoCnIX7Jg==}
- monaco-editor@0.52.2:
- resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
-
monaco-editor@0.54.0:
resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==}
@@ -3148,7 +3138,7 @@ snapshots:
'@vscode/codicons': 0.0.33
immer: 10.1.3
lodash-es: 4.17.21
- monaco-editor: 0.52.2
+ monaco-editor: 0.54.0
monaco-editor-nls: 3.1.0
normalize.css: 8.0.1
rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -4877,8 +4867,6 @@ snapshots:
monaco-editor-nls@3.1.0: {}
- monaco-editor@0.52.2: {}
-
monaco-editor@0.54.0:
dependencies:
dompurify: 3.1.7
diff --git a/website/src/App.tsx b/website/src/App.tsx
index 3800a819..3f7131bd 100644
--- a/website/src/App.tsx
+++ b/website/src/App.tsx
@@ -7,18 +7,38 @@ import extensions from './extensions';
import './languages';
import './App.css';
import { editor } from 'monaco-editor';
+import {
+ createRunStatementButton,
+ type RunStatementButtonController
+} from 'monaco-sql-languages/esm/main';
+
+/** Demo-only: one controller per editor; dispose on app unmount. Production: dispose in editor unmount. */
+const runButtonControllers = new Map();
/**
* Allow code completion when typing in snippets.
*
* You can also set configurations when creating monaco-editor instance
*/
-editor.onDidCreateEditor((editor) => {
- editor.updateOptions({
+editor.onDidCreateEditor((editorInstance) => {
+ const codeEditor = editorInstance as editor.IStandaloneCodeEditor;
+
+ codeEditor.updateOptions({
suggest: {
snippetsPreventQuickSuggestions: false
}
});
+
+ // languageId is resolved from the model on each refresh; no need to pass it at create time.
+ const runButtonController = createRunStatementButton({
+ editor: codeEditor,
+ glyphMarginHoverMessage: '运行此语句',
+ onRun: ({ statement }) => {
+ window.console.log('[run-statement-button]', statement.text);
+ }
+ });
+
+ runButtonControllers.set(codeEditor, runButtonController);
});
const instance = create({
@@ -34,6 +54,8 @@ export default function App() {
instance.render(container.current);
return () => {
+ runButtonControllers.forEach((controller) => controller.dispose());
+ runButtonControllers.clear();
instance.dispose();
};
}, []);