diff --git a/src/pages/components/CodeEditor/index.tsx b/src/pages/components/CodeEditor/index.tsx index 7209e3664..804f3d1dd 100644 --- a/src/pages/components/CodeEditor/index.tsx +++ b/src/pages/components/CodeEditor/index.tsx @@ -45,7 +45,96 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod } let edit: editor.IStandaloneDiffEditor | editor.IStandaloneCodeEditor; const inlineDiv = document.getElementById(id) as HTMLDivElement; - // @ts-ignore + const commonEditorOptions = { + folding: true, + foldingStrategy: "indentation", + automaticLayout: true, + scrollbar: { alwaysConsumeMouseWheel: false }, + overviewRulerBorder: false, + scrollBeyondLastLine: false, + + glyphMargin: true, + unicodeHighlight: { + ambiguousCharacters: false, + }, + + // https://code.visualstudio.com/docs/editing/intellisense + + // Controls whether suggestions should be accepted on commit characters. For example, in JavaScript, the semi-colon (`;`) can be a commit character that accepts a suggestion and types that character. + acceptSuggestionOnCommitCharacter: true, + + // Controls if suggestions should be accepted on 'Enter' - in addition to 'Tab'. Helps to avoid ambiguity between inserting new lines or accepting suggestions. The value 'smart' means only accept a suggestion with Enter when it makes a textual change + acceptSuggestionOnEnter: "on", + + // Controls the delay in ms after which quick suggestions will show up. + quickSuggestionsDelay: 10, + + // Controls if suggestions should automatically show up when typing trigger characters + suggestOnTriggerCharacters: true, + + // Controls if pressing tab inserts the best suggestion and if tab cycles through other suggestions + tabCompletion: "off", + + // Controls whether sorting favours words that appear close to the cursor + suggest: { + localityBonus: true, + preview: true, + }, + + // Controls how suggestions are pre-selected when showing the suggest list + suggestSelection: "first", + + // Enable word based suggestions + wordBasedSuggestions: "matchingDocuments", + + // Enable parameter hints + parameterHints: { + enabled: true, + }, + + // https://qiita.com/H-goto16/items/43802950fc5c112c316b + // https://zenn.dev/udonj/articles/ultimate-vscode-customization-2024 + // https://github.com/is0383kk/VSCode + + quickSuggestions: { + other: "inline", + comments: true, + strings: true, + }, + + fastScrollSensitivity: 10, + smoothScrolling: true, + inlineSuggest: { + enabled: true, + }, + guides: { + indentation: true, + }, + renderLineHighlightOnlyWhenFocus: true, + snippetSuggestions: "top", + + cursorBlinking: "phase", + cursorSmoothCaretAnimation: "off", + + autoIndent: "advanced", + wrappingIndent: "indent", + wordSegmenterLocales: ["ja", "zh-CN", "zh-Hant-TW"] as string[], + + renderLineHighlight: "gutter", + renderWhitespace: "selection", + renderControlCharacters: true, + dragAndDrop: false, + emptySelectionClipboard: false, + copyWithSyntaxHighlighting: false, + bracketPairColorization: { + enabled: true, + }, + mouseWheelZoom: true, + links: true, + accessibilitySupport: "off", + largeFileOptimizations: true, + colorDecorators: true, + } as const; if (diffCode) { edit = editor.createDiffEditor(inlineDiv, { hideUnchangedRegions: { @@ -53,18 +142,9 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod }, enableSplitViewResizing: false, renderSideBySide: false, - folding: true, - foldingStrategy: "indentation", - automaticLayout: true, - scrollbar: { alwaysConsumeMouseWheel: false }, - overviewRulerBorder: false, - scrollBeyondLastLine: false, readOnly: true, diffWordWrap: "off", - glyphMargin: true, - unicodeHighlight: { - ambiguousCharacters: false, - }, + ...commonEditorOptions, }); edit.setModel({ original: editor.createModel(diffCode, "javascript"), @@ -74,17 +154,8 @@ const CodeEditor: React.ForwardRefRenderFunction<{ editor: editor.IStandaloneCod edit = editor.create(inlineDiv, { language: "javascript", theme: document.body.getAttribute("arco-theme") === "dark" ? "vs-dark" : "vs", - folding: true, - foldingStrategy: "indentation", - automaticLayout: true, - scrollbar: { alwaysConsumeMouseWheel: false }, - overviewRulerBorder: false, - scrollBeyondLastLine: false, readOnly: !editable, - glyphMargin: true, - unicodeHighlight: { - ambiguousCharacters: false, - }, + ...commonEditorOptions, }); edit.setValue(code); diff --git a/src/pkg/utils/monaco-editor/config.ts b/src/pkg/utils/monaco-editor/config.ts index 60a280964..b69e4e465 100644 --- a/src/pkg/utils/monaco-editor/config.ts +++ b/src/pkg/utils/monaco-editor/config.ts @@ -9,6 +9,10 @@ const config = { // https://github.com/suren-atoyan/monaco-react/issues/75#issuecomment-1890761086 allowNonTsExtensions: true, allowJs: true, + checkJs: true, // 启用 JS 类型检查以提供更好的智能提示 + noUnusedLocals: false, // 用户脚本中可能有意声明但未使用的变量,避免无用变量警告 + noFallthroughCasesInSwitch: false, // 允许 switch 穿透,用户脚本常见模式,减少警告 + noImplicitThis: false, // 用户脚本中 this 上下文可能不明确,避免相关警告 strict: true, } as languages.typescript.CompilerOptions; diff --git a/src/pkg/utils/monaco-editor/index.ts b/src/pkg/utils/monaco-editor/index.ts index 9bcbd5ac2..6c91de174 100644 --- a/src/pkg/utils/monaco-editor/index.ts +++ b/src/pkg/utils/monaco-editor/index.ts @@ -1,6 +1,7 @@ import { globalCache, systemConfig } from "@App/pages/store/global"; import EventEmitter from "eventemitter3"; import { languages } from "monaco-editor"; +import { findGlobalInsertionInfo, updateGlobalCommentLine } from "./utils"; // 注册eslint const linterWorker = new Worker("/src/linter.worker.js"); @@ -517,6 +518,10 @@ export default function registerEditor() { // 判断有没有修复方案 const val = context.markers[i]; const code = typeof val.code === "string" ? val.code : val.code!.value; + + // ============================= + // 1) eslint-fix logic + // ============================= const fix = eslintFix.get( `${code}|${val.startLineNumber}|${val.endLineNumber}|${val.startColumn}|${val.endColumn}` ); @@ -539,6 +544,69 @@ export default function registerEditor() { isPreferred: true, }); } + + // ============================= + // 2) /* global XXX */ fix + // ============================= + + // message format usually like: "'XXX' is not defined.ESLint (no-undef)" + if (code === "no-undef") { + const message = val.message || ""; + const match = message.match(/^[^']*'([^']+)'[^']*$/); + const globalName = match && match[1]; + + if (globalName) { + const { insertLine, globalLine } = findGlobalInsertionInfo(model); + let textEdit: languages.IWorkspaceTextEdit["textEdit"]; + + if (globalLine != null) { + // there is already a /* global ... */ line → update it + const oldLine = model.getLineContent(globalLine); + const newLine = updateGlobalCommentLine(oldLine, globalName); + + textEdit = { + range: { + startLineNumber: globalLine, + startColumn: 1, + endLineNumber: globalLine, + endColumn: oldLine.length + 1, + }, + text: newLine, + }; + } else { + // no global line yet → insert a new one + textEdit = { + range: { + startLineNumber: insertLine, + startColumn: 1, + endLineNumber: insertLine, + endColumn: 1, + }, + text: `/* global ${globalName} */\n`, + }; + } + + actions.push({ + title: `将 '${globalName}' 声明为全局变量 (/* global */)`, + diagnostics: [val], + kind: "quickfix", + edit: { + edits: [ + { + resource: model.uri, + textEdit, + versionId: undefined, + }, + ], + }, + isPreferred: false, + }); + } + } + + // ============================= + // 3) disable-next-line / disable fixes + // ============================= // 添加eslint-disable-next-line和eslint-disable actions.push({ title: multiLang.addEslintDisableNextLine, diff --git a/src/pkg/utils/monaco-editor/utils.test.ts b/src/pkg/utils/monaco-editor/utils.test.ts new file mode 100644 index 000000000..6b5e2c3eb --- /dev/null +++ b/src/pkg/utils/monaco-editor/utils.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import type { editor } from "monaco-editor"; +import { escapeRegExp, findGlobalInsertionInfo, updateGlobalCommentLine } from "./utils"; + +// 创建一个简单的 Monaco Editor 模型 mock +const createMockModel = (lines: string[]): editor.ITextModel => { + return { + getLineCount: () => lines.length, + getLineContent: (lineNumber: number) => lines[lineNumber - 1] || "", + } as editor.ITextModel; +}; + +describe("findGlobalInsertionInfo", () => { + it("应该在空文件中返回第1行作为插入位置", () => { + const model = createMockModel([]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 1, globalLine: null }); + }); + + it("应该在只有空行的文件中返回第1行", () => { + const model = createMockModel(["", "", ""]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 1, globalLine: null }); + }); + + it("应该跳过单行注释找到第一个非注释行", () => { + const model = createMockModel(["// This is a comment", "// Another comment", "const x = 1;"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 3, globalLine: null }); + }); + + it("应该跳过块注释找到第一个非注释行", () => { + const model = createMockModel(["/* This is a", " multi-line comment */", "const x = 1;"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 3, globalLine: null }); + }); + + it("应该识别全局注释行", () => { + const model = createMockModel(["/* global jQuery, $ */", "const x = 1;"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 2, globalLine: 1 }); + }); + + it("应该处理包含global关键字的多行块注释", () => { + const model = createMockModel(["/* global jQuery,", " axios */", "const x = 1;"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 3, globalLine: 1 }); + }); + + it("应该跳过所有注释和空行", () => { + const model = createMockModel(["", "// Comment 1", "", "/* Block comment */", "", "const x = 1;"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 6, globalLine: null }); + }); + + it("应该处理只有注释的文件", () => { + const model = createMockModel(["// Only comments", "/* Block comment */"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 1, globalLine: null }); + }); + + it("应该处理混合注释和global注释", () => { + const model = createMockModel([ + "// Header comment", + "/* global window, document */", + "// Another comment", + "const x = 1;", + ]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 4, globalLine: 2 }); + }); + + it("应该处理单行块注释", () => { + const model = createMockModel(["/* Single line block comment */", "const x = 1;"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 2, globalLine: null }); + }); + + it("应该在有内容的行之前找到插入位置", () => { + const model = createMockModel(["", "const x = 1;", "const y = 2;"]); + const result = findGlobalInsertionInfo(model); + expect(result).toEqual({ insertLine: 2, globalLine: null }); + }); +}); + +describe("escapeRegExp", () => { + it("应该转义正则表达式特殊字符", () => { + expect(escapeRegExp("test.name")).toBe("test\\.name"); + expect(escapeRegExp("name$")).toBe("name\\$"); + expect(escapeRegExp("test[0]")).toBe("test\\[0\\]"); + expect(escapeRegExp("(test)")).toBe("\\(test\\)"); + }); + + it("应该处理普通字符串", () => { + expect(escapeRegExp("testName")).toBe("testName"); + expect(escapeRegExp("abc123")).toBe("abc123"); + }); +}); + +describe("updateGlobalCommentLine", () => { + it("如果全局变量已存在,应该返回原行", () => { + const line = "/* global jQuery, $ */"; + const result = updateGlobalCommentLine(line, "jQuery"); + expect(result).toBe(line); + }); + + it("应该在注释末尾添加新的全局变量", () => { + const line = "/* global jQuery */"; + const result = updateGlobalCommentLine(line, "axios"); + expect(result).toBe("/* global jQuery, axios */"); + }); + + it("应该在只有global关键字的注释后添加变量", () => { + const line = "/* global */"; + const result = updateGlobalCommentLine(line, "Vue"); + expect(result).toBe("/* global Vue */"); + }); + + it("应该处理以逗号结尾的注释", () => { + const line = "/* global jQuery, */"; + const result = updateGlobalCommentLine(line, "axios"); + expect(result).toBe("/* global jQuery, axios */"); + }); + + it("应该处理多个已存在的全局变量", () => { + const line = "/* global window, document, console */"; + const result = updateGlobalCommentLine(line, "fetch"); + expect(result).toBe("/* global window, document, console, fetch */"); + }); + + it("应该处理注释后有额外内容的情况", () => { + const line = "/* global jQuery */ // some comment"; + const result = updateGlobalCommentLine(line, "axios"); + expect(result).toBe("/* global jQuery, axios */ // some comment"); + }); + + it("应该处理格式不正确的注释(缺少*/)", () => { + const line = "/* global jQuery"; + const result = updateGlobalCommentLine(line, "axios"); + expect(result).toBe("/* global jQuery, axios"); + }); + + it("应该避免重复添加相同的全局变量", () => { + const line = "/* global jQuery, axios */"; + const result = updateGlobalCommentLine(line, "jQuery"); + expect(result).toBe(line); + }); + + it("应该正确处理包含特殊字符的变量名", () => { + const line = "/* global $ */"; + const result = updateGlobalCommentLine(line, "jQuery"); + expect(result).toBe("/* global $, jQuery */"); + + const result2 = updateGlobalCommentLine(result, "$"); + expect(result2).toBe(result); // $ 已经存在 + }); + + it("应该处理变量名是已存在变量子串的情况", () => { + const line = "/* global myVariable */"; + const result = updateGlobalCommentLine(line, "my"); + expect(result).toBe("/* global myVariable, my */"); + + const line2 = "/* global my */"; + const result2 = updateGlobalCommentLine(line2, "myVariable"); + expect(result2).toBe("/* global my, myVariable */"); + }); +}); diff --git a/src/pkg/utils/monaco-editor/utils.ts b/src/pkg/utils/monaco-editor/utils.ts new file mode 100644 index 000000000..978742ffb --- /dev/null +++ b/src/pkg/utils/monaco-editor/utils.ts @@ -0,0 +1,85 @@ +import type { editor } from "monaco-editor"; + +export const findGlobalInsertionInfo = (model: editor.ITextModel) => { + const lineCount = model.getLineCount(); + + let insertLine = 1; // first non-comment line + let globalLine: number | null = null; + + let line = 1; + while (line <= lineCount) { + const raw = model.getLineContent(line); + const text = raw.trim(); + + // empty line + if (text === "") { + line += 1; + continue; + } + + // single-line comment + if (text.startsWith("//")) { + line += 1; + continue; + } + + // block comment + if (text.startsWith("/*")) { + // check if this is a /* global ... */ comment + if (/^\/\*\s*global\b/.test(text)) { + globalLine = line; + } + + // skip the whole block comment + while (line <= lineCount && !model.getLineContent(line).includes("*/")) { + line += 1; + } + line += 1; + continue; + } + + // first non-comment, non-empty line = insertion point + insertLine = line; + break; + } + + // fallback (file all comments / empty) + if (insertLine > lineCount) { + insertLine = lineCount + 1; + } + + return { insertLine, globalLine }; +}; + +export const escapeRegExp = (str: string) => { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}; + +export const updateGlobalCommentLine = (oldLine: string, globalName: string) => { + // if already present, do nothing + // 使用更灵活的边界匹配,支持包含特殊字符的变量名 + const escapedName = escapeRegExp(globalName); + // 匹配前面是空白、逗号或global关键字,后面是空白、逗号或*/的情况 + const nameRegex = new RegExp("(?:^|[\\s,]|global\\s+)" + escapedName + "(?=[\\s,]|\\*/|$)"); + if (nameRegex.test(oldLine)) { + return oldLine; + } + + const endIdx = oldLine.lastIndexOf("*/"); + if (endIdx === -1) { + // weird / malformed, just append + return oldLine + ", " + globalName; + } + + const before = oldLine.slice(0, endIdx).trimEnd(); // up to before */ + const after = oldLine.slice(endIdx); // "*/" and whatever after + + // decide separator + const needsComma = + !/global\s*$/.test(before) && // not just "/* global" + !/[, ]$/.test(before); // doesn't already end with , or space + + const sep = needsComma ? ", " : " "; + + return before + sep + globalName + " " + after; +};