feat: 增强代码节点编辑器与放大阅读体验

- tinyflow-ui: 新增 CodeScriptEditor(CodeMirror 6)并支持语法高亮、上下文补全、自动括号与 _result 高亮

- tinyflow-ui: 代码节点接入引擎能力列表与节点说明提示,统一 JS/Python 编辑体验

- tinyflow-ui: 增加放大编辑模式,支持居中弹层、ESC 与点击外部关闭

- app/workflow: 对接 supportedCodeEngines 能力并透传 codeEngine provider
This commit is contained in:
2026-03-01 19:57:28 +08:00
parent beeb62c4fc
commit ac8de7dbb8
11 changed files with 1541 additions and 23 deletions

View File

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