diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary index 8e3904cadb7..2759b6f512d 100644 --- a/frontend/LICENSE-binary +++ b/frontend/LICENSE-binary @@ -211,6 +211,7 @@ Dependencies under the Apache License, Version 2.0 -------------------------------------------------------------------------------- Angular / npm packages: + - dompurify@3.3.1 - fuse.js@6.5.3 - jschardet@3.1.3 - rxjs@7.8.1 @@ -247,30 +248,40 @@ Angular / npm packages: - @ant-design/icons-angular@21.0.0 - @auth0/angular-jwt@5.1.0 - @babel/runtime@7.29.2 - - @codingame/monaco-vscode-api@8.0.4 - - @codingame/monaco-vscode-base-service-override@8.0.4 - - @codingame/monaco-vscode-configuration-service-override@8.0.4 - - @codingame/monaco-vscode-editor-api@8.0.4 - - @codingame/monaco-vscode-environment-service-override@8.0.4 - - @codingame/monaco-vscode-extensions-service-override@8.0.4 - - @codingame/monaco-vscode-files-service-override@8.0.4 - - @codingame/monaco-vscode-host-service-override@8.0.4 - - @codingame/monaco-vscode-java-default-extension@8.0.4 - - @codingame/monaco-vscode-languages-service-override@8.0.4 - - @codingame/monaco-vscode-layout-service-override@8.0.4 - - @codingame/monaco-vscode-model-service-override@8.0.4 - - @codingame/monaco-vscode-monarch-service-override@8.0.4 - - @codingame/monaco-vscode-python-default-extension@8.0.4 - - @codingame/monaco-vscode-quickaccess-service-override@8.0.4 - - @codingame/monaco-vscode-r-default-extension@8.0.4 - - @codingame/monaco-vscode-textmate-service-override@8.0.4 - - @codingame/monaco-vscode-theme-defaults-default-extension@8.0.4 - - @codingame/monaco-vscode-theme-service-override@8.0.4 + - @codingame/monaco-vscode-api@25.1.2 + - @codingame/monaco-vscode-base-service-override@25.1.2 + - @codingame/monaco-vscode-bulk-edit-service-override@25.1.2 + - @codingame/monaco-vscode-configuration-service-override@25.1.2 + - @codingame/monaco-vscode-editor-api@25.1.2 + - @codingame/monaco-vscode-editor-service-override@25.1.2 + - @codingame/monaco-vscode-environment-service-override@25.1.2 + - @codingame/monaco-vscode-extension-api@25.1.2 + - @codingame/monaco-vscode-extensions-service-override@25.1.2 + - @codingame/monaco-vscode-files-service-override@25.1.2 + - @codingame/monaco-vscode-host-service-override@25.1.2 + - @codingame/monaco-vscode-java-default-extension@25.1.2 + - @codingame/monaco-vscode-keybindings-service-override@25.1.2 + - @codingame/monaco-vscode-languages-service-override@25.1.2 + - @codingame/monaco-vscode-layout-service-override@25.1.2 + - @codingame/monaco-vscode-log-service-override@25.1.2 + - @codingame/monaco-vscode-model-service-override@25.1.2 + - @codingame/monaco-vscode-monarch-service-override@25.1.2 + - @codingame/monaco-vscode-python-default-extension@25.1.2 + - @codingame/monaco-vscode-quickaccess-service-override@25.1.2 + - @codingame/monaco-vscode-textmate-service-override@25.1.2 + - @codingame/monaco-vscode-theme-defaults-default-extension@25.1.2 + - @codingame/monaco-vscode-theme-service-override@25.1.2 + - @codingame/monaco-vscode-view-banner-service-override@25.1.2 + - @codingame/monaco-vscode-view-common-service-override@25.1.2 + - @codingame/monaco-vscode-view-status-bar-service-override@25.1.2 + - @codingame/monaco-vscode-view-title-bar-service-override@25.1.2 + - @codingame/monaco-vscode-views-service-override@25.1.2 + - @codingame/monaco-vscode-workbench-service-override@25.1.2 - @ctrl/tinycolor@3.6.1 - @ngneat/until-destroy@8.1.4 - @ngx-formly/core@6.3.12 - @ngx-formly/ng-zorro-antd@6.3.12 - - @vscode/iconv-lite-umd@0.7.0 + - @vscode/iconv-lite-umd@0.7.1 - ajv@8.10.0 - backbone@1.4.1 - balanced-match@1.0.2 @@ -283,18 +294,16 @@ Angular / npm packages: - file-saver@2.0.5 - graphlib@2.1.8 - html2canvas@1.4.1 - - java@1.0.0 - jquery@3.6.4 - json-schema-traverse@1.0.0 - jszip@3.10.1 - lib0@0.2.117 - lodash@4.18.1 - lodash-es@4.18.1 - - marked@17.0.1 + - marked@14.0.0 - mobx@4.14.1 - monaco-breakpoints@0.2.0 - - monaco-editor-wrapper@5.5.3 - - monaco-languageclient@8.8.3 + - monaco-languageclient@10.7.0 - ng-zorro-antd@21.2.2 - ngx-color-picker@12.0.1 - ngx-file-drop@16.0.0 @@ -303,23 +312,18 @@ Angular / npm packages: - papaparse@5.4.1 - plotly.js-basic-dist-min@2.29.0 - point-in-polygon@1.1.0 - - python@1.0.0 - quill-cursors@3.1.2 - - r@1.0.0 - rbush@4.0.1 - read-excel-file@5.7.1 - ring-buffer-ts@1.0.3 - style-loader@3.3.4 - - theme-defaults@1.0.0 - underscore@1.13.8 - uuid@8.3.2 - vscode-jsonrpc@8.2.0 - vscode-languageclient@9.0.1 - vscode-languageserver-protocol@3.17.5 - vscode-languageserver-types@3.17.5 - - vscode-oniguruma@1.7.0 - - vscode-textmate@9.0.0 - - vscode-ws-jsonrpc@3.3.2 + - vscode-ws-jsonrpc@3.5.0 - y-monaco@0.1.5 - y-protocols@1.0.7 - y-quill@0.1.5 diff --git a/frontend/custom-webpack.config.js b/frontend/custom-webpack.config.js index 47a804cf435..1ee52633aa5 100644 --- a/frontend/custom-webpack.config.js +++ b/frontend/custom-webpack.config.js @@ -17,46 +17,78 @@ * under the License. */ +const path = require("path"); const { LicenseWebpackPlugin } = require("license-webpack-plugin"); +const nodeModule = (...segments) => path.resolve(__dirname, "node_modules", ...segments); +const codingameCssRe = /node_modules[\\/](?:@codingame[\\/]monaco-vscode-[^\\/]+|monaco-editor|vscode)[\\/].*\.css$/; + module.exports = { module: { rules: [ + { + // codingame monaco-vscode-* ships raw assets (svg/ttf/png/woff*) that + // webpack must emit as static files rather than try to parse as JS. + test: /\.(svg|ttf|woff2?|png|jpg|jpeg|gif)$/, + include: [nodeModule("@codingame")], + type: "asset/resource", + }, { test: /\.css$/, - use: ["style-loader", "css-loader"], - include: [ - require("path").resolve(__dirname, "node_modules/monaco-editor"), - require("path").resolve(__dirname, "node_modules/monaco-breakpoints") + oneOf: [ + { + // codingame monaco-vscode-* CSS ships as Constructable Stylesheet + // imports — must skip style-loader and use css-loader's + // `exportType: 'css-style-sheet'`. + // https://github.com/CodinGame/monaco-vscode-api/wiki/Troubleshooting + test: codingameCssRe, + use: [ + { + loader: "css-loader", + options: { esModule: false, exportType: "css-style-sheet", url: true, import: true }, + }, + ], + }, + { + // monaco-breakpoints ships a plain stylesheet that needs + // style-loader so it injects at runtime. + include: [nodeModule("monaco-breakpoints")], + use: ["style-loader", "css-loader"], + }, ], }, ], - // Enable URL handling in webpack's JavaScript parser, required for loading .wasm files. - // See https://github.com/angular/angular-cli/issues/24617 - parser: { - javascript: { - url: true, - }, + }, + resolve: { + // css-loader emits relative imports (e.g. '../../../../../../../css-loader/ + // dist/runtime/api.js') computed from the source CSS location. The codingame + // monaco-vscode-* packages live one namespace level deeper (under + // `node_modules/@codingame/...`) than css-loader assumes, so the emitted + // path lands at `node_modules/@codingame/css-loader/...` instead of + // `node_modules/css-loader/...`. Alias the missing leg back to the real + // install so webpack can resolve the runtime files. + alias: { + [nodeModule("@codingame/css-loader")]: nodeModule("css-loader"), + [nodeModule("@codingame/style-loader")]: nodeModule("style-loader"), }, }, plugins: [ new LicenseWebpackPlugin({ perChunkOutput: false, outputFilename: "3rdpartylicenses.json", + // Some codingame monaco-vscode-* sub-modules don't expose a license file + // license-webpack-plugin can find; treat that as soft instead of fatal. + handleMissingLicenseText: () => null, renderLicenses: (modules) => JSON.stringify( modules + .filter((m) => m.packageJson?.name && m.packageJson?.version) .map((m) => ({ - name: m.packageJson && m.packageJson.name, - version: m.packageJson && m.packageJson.version, + name: m.packageJson.name, + version: m.packageJson.version, license: m.licenseId, })) - .filter((e) => e.name && e.version) - .sort((a, b) => - a.name === b.name - ? a.version.localeCompare(b.version) - : a.name.localeCompare(b.name), - ), + .sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version)), null, 2, ), diff --git a/frontend/package.json b/frontend/package.json index 265b0bbe4fc..4acc2ea9da6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,6 @@ "start": "concurrently --kill-others \"npx y-websocket\" \"ng serve\"", "build": "node --max-old-space-size=8192 ./node_modules/@angular/cli/bin/ng build --configuration=production --progress=false --source-map=false", "build:ci": "node --max-old-space-size=8192 ./node_modules/nx/dist/bin/nx.js build --configuration=production --progress=false --source-map=false", - "analyze": "ng build --configuration=production --stats-json && webpack-bundle-analyzer dist/stats.json", "test": "ng test --watch=false", "test:ci": "node --max-old-space-size=8192 ./node_modules/nx/dist/bin/nx.js test --watch=false --progress=false --coverage --coverage-reporters=lcovonly", "prettier:fix": "prettier --write ./src", @@ -35,9 +34,8 @@ "@angular/platform-browser-dynamic": "21.2.10", "@angular/router": "21.2.10", "@auth0/angular-jwt": "5.1.0", - "@codingame/monaco-vscode-java-default-extension": "8.0.4", - "@codingame/monaco-vscode-python-default-extension": "8.0.4", - "@codingame/monaco-vscode-r-default-extension": "8.0.4", + "@codingame/monaco-vscode-java-default-extension": "25.1.2", + "@codingame/monaco-vscode-python-default-extension": "25.1.2", "@lezer/python": "1.1.18", "@ngneat/until-destroy": "8.1.4", "@ngx-formly/core": "6.3.12", @@ -55,9 +53,8 @@ "lodash-es": "4.18.1", "marked": "17.0.1", "monaco-breakpoints": "0.2.0", - "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@8.0.4", - "monaco-editor-wrapper": "5.5.3", - "monaco-languageclient": "8.8.3", + "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@25.1.2", + "monaco-languageclient": "10.7.0", "ng-zorro-antd": "21.2.2", "ngx-color-picker": "12.0.1", "ngx-file-drop": "16.0.0", @@ -74,7 +71,6 @@ "tinyqueue": "2.0.3", "tslib": "2.3.1", "uuid": "8.3.2", - "vscode": "npm:@codingame/monaco-vscode-api@8.0.4", "y-monaco": "0.1.5", "y-protocols": "1.0.5", "y-quill": "0.1.5", @@ -84,8 +80,7 @@ "zone.js": "0.15.1" }, "resolutions": { - "vscode": "npm:@codingame/monaco-vscode-api@8.0.4", - "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@8.0.4", + "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@25.1.2", "webpack": "5.104.1", "jschardet": "portal:./tools/jschardet-stub" }, @@ -139,8 +134,7 @@ "sass": "1.71.1", "ts-proto": "2.2.0", "typescript": "5.9.3", - "vitest": "4.1.5", - "webpack-bundle-analyzer": "4.5.0" + "vitest": "4.1.5" }, "browserslist": [ "defaults", diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts index 14384524cfa..d46b19f7340 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts @@ -20,9 +20,6 @@ import { AfterViewInit, Component, Input, ViewChild } from "@angular/core"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { SafeStyle } from "@angular/platform-browser"; -import "@codingame/monaco-vscode-python-default-extension"; -import "@codingame/monaco-vscode-r-default-extension"; -import "@codingame/monaco-vscode-java-default-extension"; import { isDefined } from "../../../common/util/predicate"; import * as monaco from "monaco-editor"; import { MonacoBreakpoint } from "monaco-breakpoints"; diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts index d6a3266fd1f..677dd54c1fe 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts @@ -21,11 +21,13 @@ // spec covers the constructor, language detection, getFileSuffixByLanguage, // onFocus, getCoeditorCursorStyles, and the accept/reject annotation paths, // but cannot reach anything gated on a real Monaco editor — the -// `initAndStart` subscribe body, `initializeDiffEditor`, AI-action run -// callbacks, `handleTypeAnnotation`'s position branch, and the resize -// handler. This spec drives those by swapping the component's editorWrapper -// for a fake-with-real-DOM and running in vitest's Playwright/Chromium -// browser mode, where monaco-editor's codingame fork can be imported without +// `initializeMonacoEditor` subscribe body, `initializeDiffEditor`, AI-action +// run callbacks, `handleTypeAnnotation`'s position branch, and the resize +// handler. This spec drives those by stubbing the v10 editor seams — the +// global vscode-api init (`ensureVscodeApiStarted`) and the per-editor +// `EditorApp` — so the subscribe body runs against a fake editor, then running +// in vitest's Playwright/Chromium browser mode, where monaco-editor's codingame +// fork can be imported without // jsdom's missing-canvas / Node-Buffer-allocation tripwires (the Buffer/process // shim is wired as the first setupFile in vitest.browser.config.ts — see // src/browser-buffer-polyfill.ts). @@ -70,6 +72,38 @@ vi.mock("y-monaco", () => ({ }, })); +// monaco-languageclient v10 split the old single wrapper into a process-wide +// `MonacoVscodeApiWrapper` (started once) and a per-editor `EditorApp`. The +// component `new`s the EditorApp inside its start path, so there is no instance +// field to swap — intercept the class instead. This recording stand-in captures +// the `EditorAppConfig` handed to the constructor (the non-diff vs diff branch +// is the assertion target), records the host element passed to `start()`, and +// hands back the test's fake editor from `getEditor()`. The global vscode-api +// init is stubbed separately in `beforeEach`. +const { editorAppMock } = vi.hoisted(() => ({ + editorAppMock: { + configs: [] as unknown[], + start: vi.fn(), + getEditor: vi.fn(), + }, +})); +vi.mock("monaco-languageclient/editorApp", () => ({ + EditorApp: class { + constructor(config: unknown) { + editorAppMock.configs.push(config); + } + start(host: unknown) { + return editorAppMock.start(host); + } + getEditor() { + return editorAppMock.getEditor(); + } + dispose() { + return Promise.resolve(); + } + }, +})); + // Re-use the augmented stub from the jsdom spec so the component constructor // can resolve its highlighted operator regardless of operatorType. const baseSchema = mockOperatorMetaData.operators.find(op => op.operatorType === "PythonUDF"); @@ -134,14 +168,6 @@ function makeFakeEditor(): FakeEditor { }; } -function makeFakeWrapper(editor: FakeEditor) { - return { - initAndStart: vi.fn().mockResolvedValue(undefined), - getEditor: vi.fn(() => editor), - dispose: vi.fn(), - }; -} - describe("CodeEditorComponent (browser)", () => { let displayVersionStream$: BehaviorSubject; let aiEnabled$: BehaviorSubject; @@ -151,6 +177,15 @@ describe("CodeEditorComponent (browser)", () => { beforeEach(async () => { monacoBindingCalls.length = 0; + editorAppMock.configs.length = 0; + editorAppMock.start.mockReset().mockResolvedValue(undefined); + editorAppMock.getEditor.mockReset(); + // The global vscode-api wrapper's start() spins up codingame workers and + // pulls the default language extensions over dynamic import — neither is + // needed here. Stub the lazy initializer so the editor start path resolves + // straight through to the EditorApp seam above. + vi.spyOn(CodeEditorComponent as any, "ensureVscodeApiStarted").mockResolvedValue(undefined); + displayVersionStream$ = new BehaviorSubject(false); aiEnabled$ = new BehaviorSubject("OpenAI"); getTypeAnnotationsSpy = vi.fn().mockReturnValue(of({ choices: [{ message: { content: ": int" } }] })); @@ -182,10 +217,16 @@ describe("CodeEditorComponent (browser)", () => { workflowActionService = TestBed.inject(WorkflowActionService); }); + afterEach(() => { + // Restore the `ensureVscodeApiStarted` spy so the next test re-stubs from a + // clean slate (vitest is not configured to auto-restore mocks). + vi.restoreAllMocks(); + }); + // Builds a fixture for the highlighted operator, but defers the - // detectChanges/ngAfterViewInit step so the caller can swap in the fake - // editor wrapper and stage `code` / `formControl` before the subscribe body - // fires. Returns the fixture, the fake wrapper, and the fake editor. + // detectChanges/ngAfterViewInit step so the caller can stage `code` / + // `formControl` (and the EditorApp's fake editor) before the subscribe body + // fires. Returns the fixture, the fake editor, and the component instance. function makeFixtureWithFakes() { const predicate = { ...mockJavaUDFPredicate }; workflowActionService.addOperator(predicate, mockPoint); @@ -193,18 +234,20 @@ describe("CodeEditorComponent (browser)", () => { const fixture = TestBed.createComponent(CodeEditorComponent); const editor = makeFakeEditor(); - const wrapper = makeFakeWrapper(editor); + // The component pulls the editor back out of `EditorApp.getEditor()` inside + // its start path (and again from `adjustEditorSize`), so the mocked + // EditorApp must hand back this same fake. + editorAppMock.getEditor.mockReturnValue(editor); const c = fixture.componentInstance as any; - c.editorWrapper = wrapper; c.formControl = new FormControl({ value: "", disabled: false }); // A YText must live inside a Y.Doc to be useful; the binding stub doesn't // care, but we stage it as if it came from the shared model so the // subscribe body crosses the `if (!this.code) return;` gate. c.code = new Y.Doc().getText("code"); - return { fixture, wrapper, editor, c: c as CodeEditorComponent }; + return { fixture, editor, c: c as CodeEditorComponent }; } - // The component's subscribe path runs `from(initAndStart(...)).pipe(...)`, + // The component's subscribe path runs `from(startEditor()).pipe(...)`, // which is microtask-async. One macrotask flush after detectChanges is // enough for the RxJS chain to deliver the editor into the subscribe body. async function flush(): Promise { @@ -213,16 +256,19 @@ describe("CodeEditorComponent (browser)", () => { } it("initializeMonacoEditor: wires the editor + MonacoBinding + AI actions when code exists", async () => { - const { fixture, wrapper, editor } = makeFixtureWithFakes(); + const { fixture, editor } = makeFixtureWithFakes(); fixture.detectChanges(); await flush(); - // The non-diff config branch should fire with the editor host element. - expect(wrapper.initAndStart).toHaveBeenCalledOnce(); - const [userConfig, host] = wrapper.initAndStart.mock.calls[0]; - expect((userConfig as any).wrapperConfig.editorAppConfig.useDiffEditor).toBeUndefined(); - expect((userConfig as any).wrapperConfig.editorAppConfig.codeResources.main.uri).toMatch(/^in-memory-.*\.\.java$/); + // The non-diff branch should construct exactly one EditorApp and start it + // against the editor host element. + expect(editorAppMock.start).toHaveBeenCalledOnce(); + expect(editorAppMock.configs).toHaveLength(1); + const config = editorAppMock.configs[0] as any; + const host = editorAppMock.start.mock.calls[0][0]; + expect(config.useDiffEditor).toBeUndefined(); + expect(config.codeResources.modified.uri).toMatch(/^in-memory-.*\.java$/); expect(host).toBeInstanceOf(HTMLElement); // The subscribe body should: push readOnly via updateOptions, construct @@ -243,7 +289,7 @@ describe("CodeEditorComponent (browser)", () => { }); it("initializeDiffEditor: when displayParticularVersion is true, runs the diff config path", async () => { - const { fixture, wrapper } = makeFixtureWithFakes(); + const { fixture } = makeFixtureWithFakes(); // Seed the stream BEFORE detectChanges so the subscribe in ngAfterViewInit // picks `true` on first emission and takes the diff branch. displayVersionStream$.next(true); @@ -251,11 +297,12 @@ describe("CodeEditorComponent (browser)", () => { fixture.detectChanges(); await flush(); - expect(wrapper.initAndStart).toHaveBeenCalledOnce(); - const [userConfig] = wrapper.initAndStart.mock.calls[0]; - expect((userConfig as any).wrapperConfig.editorAppConfig.useDiffEditor).toBe(true); + expect(editorAppMock.start).toHaveBeenCalledOnce(); + expect(editorAppMock.configs).toHaveLength(1); + const config = editorAppMock.configs[0] as any; + expect(config.useDiffEditor).toBe(true); // `original` is the previous-version source, only set on the diff path. - expect((userConfig as any).wrapperConfig.editorAppConfig.codeResources.original).toBeDefined(); + expect(config.codeResources.original).toBeDefined(); }); it("setupAIAssistantActions: registers only the 'all' action when the gate is not OpenAI", async () => { diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts index 0f37f851aa2..c262036e875 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts @@ -271,16 +271,16 @@ describe("CodeEditorComponent", () => { const fixture = makeFixture(mockJavaUDFPredicate); const c = fixture.componentInstance; - // The accept path reaches into the underlying MonacoEditorLanguageClientWrapper - // for `.getEditor()` and into the YText `.code` for `.insert()`. Both are - // private so we stub them through bracket access to a minimum that lets - // insertTypeAnnotations no-op cleanly. `dispose` is needed because - // ngOnDestroy fires at teardown and calls it. - (c as any).editorWrapper = { + // The accept path reaches into the underlying EditorApp for `.getEditor()` + // and into the YText `.code` for `.insert()`. Both are private so we stub + // them through bracket access to a minimum that lets insertTypeAnnotations + // no-op cleanly. `dispose` is needed because ngOnDestroy fires at teardown + // and calls it. + (c as any).editorApp = { getEditor: () => ({ getModel: () => ({ getOffsetAt: () => 0 }), }), - dispose: vi.fn(), + dispose: vi.fn().mockResolvedValue(undefined), }; (c as any).code = { insert: vi.fn() }; diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts index a0838e6e459..31226780f82 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.ts @@ -33,7 +33,7 @@ import { WorkflowVersionService } from "../../../dashboard/service/user/workflow import type { Text as YText } from "yjs"; import { getWebsocketUrl } from "src/app/common/util/url"; import { MonacoBinding } from "y-monaco"; -import { catchError, from, of, Subject, take, timeout } from "rxjs"; +import { from, Subject, take } from "rxjs"; import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service"; import { DomSanitizer, SafeStyle } from "@angular/platform-browser"; import { Coeditor } from "../../../common/type/user"; @@ -41,13 +41,16 @@ import { YType } from "../../types/shared-editing.interface"; import { FormControl } from "@angular/forms"; import { AIAssistantService, TypeAnnotationResponse } from "../../service/ai-assistant/ai-assistant.service"; import { AnnotationSuggestionComponent } from "./annotation-suggestion.component"; -import { MonacoEditorLanguageClientWrapper, UserConfig } from "monaco-editor-wrapper"; import * as monaco from "monaco-editor"; -import "@codingame/monaco-vscode-python-default-extension"; -import "@codingame/monaco-vscode-r-default-extension"; -import "@codingame/monaco-vscode-java-default-extension"; +import { + MonacoVscodeApiWrapper, + type MonacoVscodeApiConfig, + getEnhancedMonacoEnvironment, +} from "monaco-languageclient/vscodeApiWrapper"; +import { LanguageClientWrapper, type LanguageClientConfig } from "monaco-languageclient/lcwrapper"; +import { EditorApp, type EditorAppConfig } from "monaco-languageclient/editorApp"; import { isDefined } from "../../../common/util/predicate"; -import { filter, switchMap } from "rxjs/operators"; +import { filter } from "rxjs/operators"; import { BreakpointConditionInputComponent } from "./breakpoint-condition-input/breakpoint-condition-input.component"; import { CodeDebuggerComponent } from "./code-debugger.component"; import { GuiConfigService } from "src/app/common/service/gui-config.service"; @@ -105,7 +108,9 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy public language: string = ""; public languageTitle: string = ""; - private editorWrapper: MonacoEditorLanguageClientWrapper = new MonacoEditorLanguageClientWrapper(); + private static apiWrapperStartPromise?: Promise; + private editorApp?: EditorApp; + private languageClientWrapper?: LanguageClientWrapper; private monacoBinding?: MonacoBinding; // Boolean to determine whether the suggestion UI should be shown @@ -124,14 +129,16 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy public codeDebuggerComponent!: Type | null; public editorToPass!: MonacoEditor; - private generateLanguageTitle(language: string): string { - return `${language.charAt(0).toUpperCase()}${language.slice(1)} UDF`; - } - - setLanguage(newLanguage: string) { - this.language = newLanguage; - this.languageTitle = this.generateLanguageTitle(newLanguage); - } + // Operator-type → editor language. The R types are kept on the frontend + // even though Texera's R UDF backend now ships as a separate plugin — + // when that plugin is installed, the workflow may still surface `RUDF` / + // `RUDFSource` operators and the editor needs to open them in R mode. + private static readonly PYTHON_OPERATOR_TYPES: ReadonlySet = new Set([ + "PythonUDFV2", + "PythonUDFSourceV2", + "DualInputPortsPythonUDFV2", + ]); + private static readonly R_OPERATOR_TYPES: ReadonlySet = new Set(["RUDFSource", "RUDF"]); constructor( private sanitizer: DomSanitizer, @@ -143,18 +150,14 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy ) { this.currentOperatorId = this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()[0]; const operatorType = this.workflowActionService.getTexeraGraph().getOperator(this.currentOperatorId).operatorType; - - if (operatorType === "RUDFSource" || operatorType === "RUDF") { - this.setLanguage("r"); - } else if ( - operatorType === "PythonUDFV2" || - operatorType === "PythonUDFSourceV2" || - operatorType === "DualInputPortsPythonUDFV2" - ) { - this.setLanguage("python"); + if (CodeEditorComponent.PYTHON_OPERATOR_TYPES.has(operatorType)) { + this.language = "python"; + } else if (CodeEditorComponent.R_OPERATOR_TYPES.has(operatorType)) { + this.language = "r"; } else { - this.setLanguage("java"); + this.language = "java"; } + this.languageTitle = `${this.language[0].toUpperCase()}${this.language.slice(1)} UDF`; this.workflowActionService.getTexeraGraph().updateSharedModelAwareness("editingCode", true); this.title = this.workflowActionService.getTexeraGraph().getOperator(this.currentOperatorId).customDisplayName; this.code = ( @@ -187,30 +190,113 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy this.workflowActionService.getTexeraGraph().updateSharedModelAwareness("editingCode", false); localStorage.setItem(this.currentOperatorId, this.containerElement.nativeElement.style.cssText); - if (isDefined(this.monacoBinding)) { - this.monacoBinding.destroy(); - } - - this.editorWrapper.dispose(true); + this.monacoBinding?.destroy(); + this.languageClientWrapper?.dispose().catch(() => {}); + this.languageClientWrapper = undefined; + this.editorApp?.dispose().catch(() => {}); + this.editorApp = undefined; - if (isDefined(this.workflowVersionStreamSubject)) { - this.workflowVersionStreamSubject.next(); - this.workflowVersionStreamSubject.complete(); - } + this.workflowVersionStreamSubject.next(); + this.workflowVersionStreamSubject.complete(); } /** * Specify the co-editor's cursor style. This step is missing from MonacoBinding. + * + * `coeditor.clientId` and `coeditor.color` come from yjs awareness state, + * which any peer can write to. Both are interpolated into a `"; - return this.sanitizer.bypassSecurityTrustHtml(textCSS); + `.yRemoteSelection-${id} { background-color: ${selectionBg}}` + + `.yRemoteSelectionHead-${id}::after { border-color: ${color}}` + + `.yRemoteSelectionHead-${id} { border-color: ${color}}` + + "" + ); + } + + // Allow-lists for the two awareness-derived values that flow into a `