feat: 增强代码节点编辑器与放大阅读体验
- tinyflow-ui: 新增 CodeScriptEditor(CodeMirror 6)并支持语法高亮、上下文补全、自动括号与 _result 高亮 - tinyflow-ui: 代码节点接入引擎能力列表与节点说明提示,统一 JS/Python 编辑体验 - tinyflow-ui: 增加放大编辑模式,支持居中弹层、ESC 与点击外部关闭 - app/workflow: 对接 supportedCodeEngines 能力并透传 codeEngine provider
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createBusinessCompletions,
|
||||
normalizeCodeEngine,
|
||||
resolveBusinessCompletionContext,
|
||||
shouldAutoAppendCallParens,
|
||||
shouldSkipBusinessCompletion
|
||||
} from './codeCompletion';
|
||||
|
||||
describe('codeCompletion utils', () => {
|
||||
it('should normalize engine aliases', () => {
|
||||
expect(normalizeCodeEngine('py')).toBe('python');
|
||||
expect(normalizeCodeEngine('python')).toBe('python');
|
||||
expect(normalizeCodeEngine('js')).toBe('javascript');
|
||||
expect(normalizeCodeEngine('javascript')).toBe('javascript');
|
||||
expect(normalizeCodeEngine('')).toBe('javascript');
|
||||
});
|
||||
|
||||
it('should create business completion list with stable priority and dedupe', () => {
|
||||
const completions = createBusinessCompletions('python', [
|
||||
{ name: 'input.text', resolved: true },
|
||||
{ name: 'input.text', resolved: false },
|
||||
{ name: 'question', resolved: false }
|
||||
]);
|
||||
|
||||
expect(completions[0]).toMatchObject({
|
||||
label: '_result',
|
||||
type: 'variable'
|
||||
});
|
||||
|
||||
expect(completions.filter((item) => item.label === 'input.text').length).toBe(1);
|
||||
expect(completions.find((item) => item.label === 'input.text')).toMatchObject({
|
||||
apply: '{{input.text}}',
|
||||
detail: '参数模板'
|
||||
});
|
||||
expect(completions.find((item) => item.label === 'question')).toMatchObject({
|
||||
apply: '{{question}}',
|
||||
detail: '参数模板(未映射)'
|
||||
});
|
||||
|
||||
expect(completions.some((item) => item.type === 'snippet' && item.label === 'if-else')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect blocked nodes for comment/string/property contexts', () => {
|
||||
expect(shouldSkipBusinessCompletion('Comment')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('LineComment')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('String')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('TemplateString')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('PropertyName')).toBe(true);
|
||||
expect(shouldSkipBusinessCompletion('VariableName')).toBe(false);
|
||||
});
|
||||
|
||||
it('should resolve template context for parameter token completion', () => {
|
||||
const source = "value = {{input.use";
|
||||
const pos = source.length;
|
||||
const context = resolveBusinessCompletionContext(source, pos);
|
||||
expect(context).toBeTruthy();
|
||||
expect(context?.from).toBe(8);
|
||||
expect(context?.validFor).toBeInstanceOf(RegExp);
|
||||
expect(context?.validFor.source).toBe('^[\\w.]*$');
|
||||
});
|
||||
|
||||
it('should resolve word context and skip property access context', () => {
|
||||
const source = '_result.value';
|
||||
expect(resolveBusinessCompletionContext(source, source.length)).toBeNull();
|
||||
|
||||
const wordSource = 'ret';
|
||||
const context = resolveBusinessCompletionContext(wordSource, wordSource.length);
|
||||
expect(context).toBeTruthy();
|
||||
expect(context?.from).toBe(0);
|
||||
expect(context?.validFor).toBeInstanceOf(RegExp);
|
||||
expect(context?.validFor.source).toBe('^[\\w$]*$');
|
||||
});
|
||||
|
||||
it('should auto append call parens for function/method completions only', () => {
|
||||
expect(shouldAutoAppendCallParens({ label: 'print', type: 'function' }, '')).toBe(true);
|
||||
expect(shouldAutoAppendCallParens({ label: 'run', type: 'method' }, '')).toBe(true);
|
||||
expect(shouldAutoAppendCallParens({ label: 'count', type: 'variable' }, '')).toBe(false);
|
||||
expect(shouldAutoAppendCallParens({ label: 'print', type: 'function' }, '(')).toBe(false);
|
||||
expect(shouldAutoAppendCallParens({ label: 'call', type: 'function', apply: 'call()' }, '')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { Completion, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import type { ParameterCandidate } from './paramToken';
|
||||
|
||||
export type CodeEngine = 'javascript' | 'python';
|
||||
|
||||
export interface BusinessCompletionConfig {
|
||||
engine: CodeEngine;
|
||||
paramCandidates: ParameterCandidate[];
|
||||
}
|
||||
|
||||
interface CompletionContextHint {
|
||||
from: number;
|
||||
validFor: RegExp;
|
||||
}
|
||||
|
||||
const BLOCKED_NODE_NAMES = new Set([
|
||||
'String',
|
||||
'TemplateString',
|
||||
'FormatString',
|
||||
'Comment',
|
||||
'LineComment',
|
||||
'BlockComment',
|
||||
'PropertyName',
|
||||
'PrivatePropertyName'
|
||||
]);
|
||||
|
||||
const JS_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [
|
||||
{
|
||||
label: 'if-else',
|
||||
detail: '条件分支',
|
||||
insert: "if (condition) {\n _result.value = value;\n} else {\n _result.value = null;\n}"
|
||||
},
|
||||
{
|
||||
label: 'for-of',
|
||||
detail: '遍历数组',
|
||||
insert: "for (const item of items) {\n // TODO\n}\n_result.done = true;"
|
||||
},
|
||||
{
|
||||
label: 'result-object',
|
||||
detail: '返回对象',
|
||||
insert: "_result.message = 'ok';\n_result.data = data;"
|
||||
}
|
||||
];
|
||||
|
||||
const PYTHON_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [
|
||||
{
|
||||
label: 'if-else',
|
||||
detail: '条件分支',
|
||||
insert: "if condition:\n _result['value'] = value\nelse:\n _result['value'] = None"
|
||||
},
|
||||
{
|
||||
label: 'for-loop',
|
||||
detail: '遍历数组',
|
||||
insert: "for item in items:\n # TODO\n pass\n_result['done'] = True"
|
||||
},
|
||||
{
|
||||
label: 'result-object',
|
||||
detail: '返回对象',
|
||||
insert: "_result['message'] = 'ok'\n_result['data'] = data"
|
||||
}
|
||||
];
|
||||
|
||||
export function normalizeCodeEngine(rawEngine?: string): CodeEngine {
|
||||
const normalized = (rawEngine || 'js').trim().toLowerCase();
|
||||
if (normalized === 'python' || normalized === 'py') {
|
||||
return 'python';
|
||||
}
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
export function shouldSkipBusinessCompletion(nodeName: string): boolean {
|
||||
if (!nodeName) {
|
||||
return false;
|
||||
}
|
||||
if (BLOCKED_NODE_NAMES.has(nodeName)) {
|
||||
return true;
|
||||
}
|
||||
if (nodeName.endsWith('Comment')) {
|
||||
return true;
|
||||
}
|
||||
return nodeName.includes('String');
|
||||
}
|
||||
|
||||
export function createBusinessCompletions(
|
||||
targetEngine: CodeEngine,
|
||||
candidates: ParameterCandidate[]
|
||||
): Completion[] {
|
||||
const resultCompletion: Completion = {
|
||||
label: '_result',
|
||||
type: 'variable',
|
||||
detail: '代码节点输出对象',
|
||||
boost: 900
|
||||
};
|
||||
|
||||
const parameterCompletions: Completion[] = candidates.map((candidate) => ({
|
||||
label: candidate.name,
|
||||
type: 'variable',
|
||||
detail: candidate.resolved ? '参数模板' : '参数模板(未映射)',
|
||||
apply: `{{${candidate.name}}}`,
|
||||
boost: 700
|
||||
}));
|
||||
|
||||
const snippetCompletions: Completion[] = (targetEngine === 'python' ? PYTHON_SNIPPETS : JS_SNIPPETS).map(
|
||||
(snippet) => ({
|
||||
label: snippet.label,
|
||||
type: 'snippet',
|
||||
detail: snippet.detail,
|
||||
apply: snippet.insert,
|
||||
boost: 500
|
||||
})
|
||||
);
|
||||
|
||||
return dedupeCompletions([resultCompletion, ...parameterCompletions, ...snippetCompletions]);
|
||||
}
|
||||
|
||||
function dedupeCompletions(items: Completion[]): Completion[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: Completion[] = [];
|
||||
for (const item of items) {
|
||||
const key = `${item.label}::${item.type || ''}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function resolveTemplateContext(source: string, pos: number): CompletionContextHint | null {
|
||||
const start = Math.max(0, pos - 300);
|
||||
const prefix = source.slice(start, pos);
|
||||
const match = /\{\{\s*([\w.]*)$/.exec(prefix);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from: pos - match[0].length,
|
||||
validFor: /^[\w.]*$/
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWordContext(source: string, pos: number): CompletionContextHint | null {
|
||||
let from = pos;
|
||||
while (from > 0 && /[\w$]/.test(source[from - 1])) {
|
||||
from -= 1;
|
||||
}
|
||||
if (from === pos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (from > 0 && source[from - 1] === '.') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from,
|
||||
validFor: /^[\w$]*$/
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBusinessCompletionContext(source: string, pos: number): CompletionContextHint | null {
|
||||
return resolveTemplateContext(source, pos) || resolveWordContext(source, pos);
|
||||
}
|
||||
|
||||
export function createBusinessCompletionSource(config: BusinessCompletionConfig): CompletionSource {
|
||||
return (context) => {
|
||||
const node = syntaxTree(context.state).resolveInner(context.pos, -1);
|
||||
if (shouldSkipBusinessCompletion(node.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docText = context.state.doc.toString();
|
||||
const resolved = resolveBusinessCompletionContext(docText, context.pos);
|
||||
if (!resolved && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: CompletionResult = {
|
||||
from: resolved?.from ?? context.pos,
|
||||
options: createBusinessCompletions(config.engine, config.paramCandidates)
|
||||
};
|
||||
|
||||
if (resolved?.validFor) {
|
||||
result.validFor = resolved.validFor;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAutoAppendCallParens(completion: Completion | null | undefined, nextChar: string): boolean {
|
||||
if (!completion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const completionType = (completion.type || '').toLowerCase();
|
||||
if (completionType !== 'function' && completionType !== 'method') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextChar === '(') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof completion.apply === 'string' && completion.apply.includes('(')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof completion.apply === 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user