diff --git a/README-zh_CN.md b/README-zh_CN.md index fec76452..6725ff66 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -164,6 +164,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 e7264c32..74cf8c24 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,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/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/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/test/all.js b/test/all.js index 6158c5e3..de2ccf17 100644 --- a/test/all.js +++ b/test/all.js @@ -103,10 +103,13 @@ requirejs( function () { let files; try { - files = glob.sync('out/amd/languages/*/*.test.js', { - cwd: path.dirname(__dirname), - dot: true - }); + files = glob.sync( + ['out/amd/languages/*/*.test.js', 'out/amd/runStatementButton/*.test.js'], + { + cwd: path.dirname(__dirname), + dot: true + } + ); } catch (err) { console.log(err); return; 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(); }; }, []);