diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor-host.directive.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-editor-host.directive.spec.ts
new file mode 100644
index 00000000000..71a58711553
--- /dev/null
+++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor-host.directive.spec.ts
@@ -0,0 +1,89 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnDestroy } from "@angular/core";
+import { TestBed } from "@angular/core/testing";
+import { CodeEditorHostDirective } from "./code-editor-host.directive";
+import { CodeEditorService } from "../../service/code-editor/code-editor.service";
+
+/** A view that hosts the editor, as the operator canvas and the Form View do. */
+@Component({
+ template: "",
+ imports: [CodeEditorHostDirective],
+})
+class HostingViewComponent {}
+
+/** Stands in for the editor: what the host holds, and what must be torn down with the view. */
+@Component({ template: "editor" })
+class HostedComponent implements OnDestroy {
+ static destroyed = 0;
+ ngOnDestroy(): void {
+ HostedComponent.destroyed++;
+ }
+}
+
+describe("CodeEditorHostDirective", () => {
+ let service: CodeEditorService;
+
+ beforeEach(async () => {
+ HostedComponent.destroyed = 0;
+ await TestBed.configureTestingModule({ imports: [HostingViewComponent, HostedComponent] }).compileComponents();
+ service = TestBed.inject(CodeEditorService);
+ });
+
+ it("attaches its outlet as the editor's host when the view is built", () => {
+ expect(service.host).toBeUndefined();
+
+ const fixture = TestBed.createComponent(HostingViewComponent);
+ fixture.detectChanges();
+
+ expect(service.host).toBeDefined();
+ // The real outlet: the editor is created into it.
+ const ref = service.host!.createComponent(HostedComponent);
+ expect(ref.location.nativeElement.textContent).toBe("editor");
+ });
+
+ it("closes what it hosts and lets go of the host when the view is destroyed", () => {
+ const fixture = TestBed.createComponent(HostingViewComponent);
+ fixture.detectChanges();
+ service.host!.createComponent(HostedComponent);
+
+ fixture.destroy();
+
+ // The editor's own teardown ran, by the view's (it writes awareness and saves its geometry
+ // there) ...
+ expect(HostedComponent.destroyed).toBe(1);
+ // ... and nothing claims to host an editor any more.
+ expect(service.host).toBeUndefined();
+ });
+
+ it("lets go only of its own outlet, not of a host that has since replaced it", () => {
+ const first = TestBed.createComponent(HostingViewComponent);
+ first.detectChanges();
+ const firstHost = service.host;
+ const second = TestBed.createComponent(HostingViewComponent);
+ second.detectChanges();
+ expect(service.host).not.toBe(firstHost);
+
+ first.destroy();
+
+ expect(service.host).toBeDefined();
+ expect(service.host).not.toBe(firstHost);
+ });
+});
diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor-host.directive.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-editor-host.directive.ts
new file mode 100644
index 00000000000..d7fb95d6127
--- /dev/null
+++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor-host.directive.ts
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Directive, OnDestroy, ViewContainerRef } from "@angular/core";
+import { CodeEditorService } from "../../service/code-editor/code-editor.service";
+
+/**
+ * Marks the outlet the code editor opens into, on an `` in a view's template. Each view
+ * that shows operator properties -- the operator canvas and the Form View -- carries one, and the
+ * view in front is the editor's host: its outlet is attached to CodeEditorService when the view's
+ * template is built, and detached when the view is destroyed -- which closes an editor still open in
+ * it too, since the view's teardown destroys what its outlet holds, before this hook runs. The views
+ * are sibling routes and never overlap, so there is one host at a time; a late destroy still lets go
+ * only of its own.
+ */
+@Directive({
+ selector: "[texeraCodeEditorHost]",
+ standalone: true,
+})
+export class CodeEditorHostDirective implements OnDestroy {
+ constructor(
+ private readonly outlet: ViewContainerRef,
+ private readonly codeEditorService: CodeEditorService
+ ) {
+ this.codeEditorService.attachHost(this.outlet);
+ }
+
+ ngOnDestroy(): void {
+ this.codeEditorService.detachHost(this.outlet);
+ }
+}
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 fac2b91dac9..28ef4424377 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
@@ -232,16 +232,33 @@ describe("CodeEditorComponent (browser)", () => {
vi.restoreAllMocks();
});
- // Builds a fixture for the highlighted operator, but defers the
- // 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 };
+ // Builds a fixture for a Java UDF operator, told its operator the way the
+ // codearea button tells it, but defers the detectChanges step (ngOnInit +
+ // ngAfterViewInit) so the caller can stage `formControl`, the shared text
+ // and the EditorApp's fake editor before the subscribe body fires. The
+ // shared text is staged on the graph, where ngOnInit reads it from: a `code`
+ // property on the predicate becomes a Y.Text on the shared
+ // operator-properties map, and that is what the subscribe body binds to (and
+ // crosses the `if (!this.code) return;` gate with). Returns the fixture, the
+ // fake editor, the component instance and that shared text (undefined when
+ // staged without one).
+ function makeFixtureWithFakes(options: { withSharedCode?: boolean } = {}) {
+ const { withSharedCode = true } = options;
+ const predicate = { ...mockJavaUDFPredicate, operatorProperties: withSharedCode ? { code: "" } : {} };
workflowActionService.addOperator(predicate, mockPoint);
workflowActionService.getJointGraphWrapper().highlightOperators(predicate.operatorID);
+ const sharedCode = withSharedCode
+ ? ((
+ workflowActionService
+ .getTexeraGraph()
+ .getSharedOperatorType(predicate.operatorID)
+ .get("operatorProperties") as any
+ ).get("code") as Y.Text)
+ : undefined;
const fixture = TestBed.createComponent(CodeEditorComponent);
+ // The opener names the operator; see makeFixture in the jsdom spec.
+ fixture.componentRef.setInput("currentOperatorId", predicate.operatorID);
const editor = makeFakeEditor();
// The component pulls the editor back out of `EditorApp.getEditor()` inside
// its start path (and again from `adjustEditorSize`), so the mocked
@@ -249,11 +266,7 @@ describe("CodeEditorComponent (browser)", () => {
editorAppMock.getEditor.mockReturnValue(editor);
const c = fixture.componentInstance as any;
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, editor, c: c as CodeEditorComponent };
+ return { fixture, editor, c: c as CodeEditorComponent, sharedCode };
}
// The component's subscribe path runs `from(startEditor()).pipe(...)`,
@@ -367,8 +380,7 @@ describe("CodeEditorComponent (browser)", () => {
});
it("initializeMonacoEditor: leaves the binding unwired when the operator has no shared code", async () => {
- const { fixture, editor, c } = makeFixtureWithFakes();
- (c as any).code = undefined;
+ const { fixture, editor } = makeFixtureWithFakes({ withSharedCode: false });
fixture.detectChanges();
await flush();
@@ -412,10 +424,8 @@ describe("CodeEditorComponent (browser)", () => {
});
it("initializeDiffEditor: diffs this operator's latest-version code against the shared text", async () => {
- const { fixture, c } = makeFixtureWithFakes();
- const sharedText = new Y.Doc().getText("code");
- sharedText.insert(0, "shared editing text");
- (c as any).code = sharedText;
+ const { fixture, sharedCode } = makeFixtureWithFakes();
+ sharedCode!.insert(0, "shared editing text");
// The decoy operator is listed FIRST, so picking anything other than the
// entry matching `currentOperatorId` surfaces the wrong version.
vi.spyOn(workflowActionService, "getTempWorkflow").mockReturnValue({
diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.html b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.html
index 4de5a6ef22a..145a68d9169 100644
--- a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.html
+++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.html
@@ -22,7 +22,7 @@
id="code-editor-container"
class="box"
cdkDrag
- cdkDragBoundary="texera-workspace"
+ cdkDragBoundary="texera-workspace, texera-workflow-form"
(focusin)="onFocus()">
{
workflowActionService.addOperator(predicate, mockPoint);
workflowActionService.getJointGraphWrapper().highlightOperators(predicate.operatorID);
const fixture = TestBed.createComponent(CodeEditorComponent);
+ // The opener names the operator (the panel's button reads the highlight, the form's its
+ // binding); the highlight above is for what still reads it, such as onFocus.
+ fixture.componentRef.setInput("currentOperatorId", predicate.operatorID);
+ // The opener hands the editor the field's control before the first check; the AI actions read
+ // its disabled state. Editable here; the read-only test stages its own.
+ fixture.componentInstance.formControl = new FormControl("");
fixture.detectChanges();
return fixture;
}
- it("creates with the highlighted operator", () => {
+ it("binds to the operator it is given", () => {
const fixture = makeFixture(mockJavaUDFPredicate);
expect(fixture.componentInstance).toBeTruthy();
expect(fixture.componentInstance.currentOperatorId).toBe(mockJavaUDFPredicate.operatorID);
});
+ it("keeps the operator it is given when another one is highlighted (a Form View editor)", () => {
+ // The form's field names its operator; the highlight, if any, is a step the reader is looking at.
+ const given = { ...buildPredicateWithCode("given-op", "PythonUDFV2", "x = 1"), customDisplayName: "Given" };
+ const highlighted = buildPredicate("highlighted-op", "PythonUDFV2");
+ workflowActionService.addOperator(given, mockPoint);
+ workflowActionService.addOperator(highlighted, mockPoint);
+ workflowActionService.getJointGraphWrapper().highlightOperators(highlighted.operatorID);
+ const fixture = TestBed.createComponent(CodeEditorComponent);
+ fixture.componentRef.setInput("currentOperatorId", given.operatorID);
+
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.currentOperatorId).toBe(given.operatorID);
+ expect(fixture.componentInstance.title).toBe("Given");
+ });
+
it("shows UI parameter parser errors for the edited operator", () => {
const predicate = buildPredicate("python-with-invalid-parameters", "PythonUDFV2");
makeFixture(predicate);
@@ -278,9 +300,9 @@ describe("CodeEditorComponent", () => {
expect(notificationServiceMock.error).not.toHaveBeenCalled();
});
- // Language detection — the constructor maps `RUDFSource` / `RUDF` to `r`,
+ // Language detection — ngOnInit maps `RUDFSource` / `RUDF` to `r`,
// the three V2-era Python operator types to `python`, and anything else
- // to `java`. The exact branch lives in the constructor; the public
+ // to `java`. The exact branch lives in ngOnInit; the public
// `language` field is what the rest of the editor (LSP wiring, file-
// suffix selection) keys off.
@@ -381,6 +403,7 @@ describe("CodeEditorComponent", () => {
fixture.componentInstance.onFocus();
expect(highlightSpy).toHaveBeenCalledWith(mockJavaUDFPredicate.operatorID);
});
+
});
describe("rejectCurrentAnnotation", () => {
@@ -454,6 +477,21 @@ describe("CodeEditorComponent", () => {
});
describe("AI assistant action wiring", () => {
+ // Monaco's readOnly stops typing, not the context-menu actions, and accepting a type annotation
+ // inserts into the shared code; a read-only viewer's editor must not offer them (#8760).
+ it("registers no AI actions for a read-only editor, and both for an editable one", () => {
+ const fixture = makeFixture(mockJavaUDFPredicate);
+ const readOnly = makeFakeEditor();
+ fixture.componentInstance.formControl = new FormControl({ value: "", disabled: true });
+ (fixture.componentInstance as any).setupAIAssistantActions(asEditor(readOnly));
+ expect(readOnly.addAction).not.toHaveBeenCalled();
+
+ const editable = makeFakeEditor();
+ fixture.componentInstance.formControl = new FormControl("");
+ (fixture.componentInstance as any).setupAIAssistantActions(asEditor(editable));
+ expect(editable.addAction).toHaveBeenCalledTimes(2);
+ });
+
// setupAIAssistantActions checks an AI-provider flag (OpenAI vs others)
// before deciding whether to register the per-selection 'Add Type
// Annotation' action. We can't drive the action body without a real
@@ -477,6 +515,7 @@ describe("CodeEditorComponent", () => {
wfActions.addOperator(mockJavaUDFPredicate, mockPoint);
wfActions.getJointGraphWrapper().highlightOperators(mockJavaUDFPredicate.operatorID);
const fixture = TestBed.createComponent(CodeEditorComponent);
+ fixture.componentRef.setInput("currentOperatorId", mockJavaUDFPredicate.operatorID);
fixture.detectChanges();
const checked = await TestBed.inject(AIAssistantService).checkAIAssistantEnabled().toPromise();
expect(checked).toBe("OpenAI");
@@ -1313,6 +1352,7 @@ describe("CodeEditorComponent", () => {
if (tempOperators) stageTempWorkflow(tempOperators);
TestBed.inject(WorkflowVersionService).setDisplayParticularVersion(true);
const fixture = TestBed.createComponent(CodeEditorComponent);
+ fixture.componentRef.setInput("currentOperatorId", predicate.operatorID);
fixture.detectChanges();
await waitFor(() => Boolean(diffModelOf(fixture.componentInstance)), "the mounted diff editor");
return fixture.componentInstance;
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 d68128312eb..18b28ff5a90 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
@@ -23,7 +23,9 @@ import {
ComponentRef,
ElementRef,
HostListener,
+ Input,
OnDestroy,
+ OnInit,
Type,
ViewChild,
} from "@angular/core";
@@ -93,14 +95,18 @@ export const LANGUAGE_SERVER_CONNECTION_TIMEOUT_MS = 1000;
AnnotationSuggestionComponent,
],
})
-export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy {
+export class CodeEditorComponent implements OnInit, AfterViewInit, SafeStyle, OnDestroy {
@ViewChild("editor", { static: true }) editorElement!: ElementRef;
@ViewChild("container", { static: true }) containerElement!: ElementRef;
@ViewChild(AnnotationSuggestionComponent) annotationSuggestion!: AnnotationSuggestionComponent;
@ViewChild(BreakpointConditionInputComponent) breakpointConditionInput!: BreakpointConditionInputComponent;
private code?: YText;
private workflowVersionStreamSubject: Subject = new Subject();
- public currentOperatorId!: string;
+ /**
+ * The operator whose code this edits, given by the opener: the operator property panel's button
+ * reads the highlighted operator, the Form View's the operator its field is bound to.
+ */
+ @Input() currentOperatorId!: string;
public title: string | undefined;
public formControl!: FormControl;
@@ -150,8 +156,10 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
private config: GuiConfigService,
private uiUdfParametersSyncService: UiUdfParametersSyncService,
private notificationService: NotificationService
- ) {
- this.currentOperatorId = this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()[0];
+ ) {}
+
+ ngOnInit(): void {
+ // Everything here keys on currentOperatorId, an input set after construction.
const operatorType = this.workflowActionService.getTexeraGraph().getOperator(this.currentOperatorId).operatorType;
if (CodeEditorComponent.PYTHON_OPERATOR_TYPES.has(operatorType)) {
this.language = "python";
@@ -468,6 +476,11 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
}
private setupAIAssistantActions(editor: MonacoEditor) {
+ // Monaco's readOnly stops typing, not the context-menu actions, and accepting a type annotation
+ // inserts into the shared code: a read-only viewer's editor offers none (#8760).
+ if (this.formControl.disabled) {
+ return;
+ }
// Check if the AI provider is "openai"
this.aiAssistantService
.checkAIAssistantEnabled()
@@ -679,6 +692,8 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
this.editorApp?.getEditor()?.layout();
}
onFocus() {
+ // The wrapper's own highlight, as it always was, not WorkflowActionService's: that one also
+ // announces the highlight to co-editors and switches multi-select mode, neither wanted on focus.
this.workflowActionService.getJointGraphWrapper().highlightOperators(this.currentOperatorId);
}
}
diff --git a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.html b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.html
index cb3a4cef63a..0d91439c474 100644
--- a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.html
+++ b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.html
@@ -24,8 +24,8 @@
nzShape="round"
nzType="primary"
[disabled]="isEditorOpen"
- [ngClass]="{ 'readonly': this.field.formControl.disabled }">
- Edit code content
- View code content
+ [ngClass]="{ 'readonly': this.field.formControl.disabled || inspect }">
+ Edit code content
+ View code content
diff --git a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts
index 7bf63d610e5..eb2005ac8c0 100644
--- a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts
+++ b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts
@@ -17,8 +17,10 @@
* under the License.
*/
+import { reflectComponentType } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { CodeareaCustomTemplateComponent } from "./codearea-custom-template.component";
+import { CodeEditorComponent } from "../code-editor-dialog/code-editor.component";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service";
@@ -56,6 +58,52 @@ describe("CodeareaCustomTemplateComponent", () => {
expect(component).toBeTruthy();
});
+ describe("what the button says", () => {
+ const label = () =>
+ (fixture.nativeElement.querySelector("#editor-button button") as HTMLElement).textContent!.trim();
+ const greyed = () =>
+ (fixture.nativeElement.querySelector("#editor-button button") as HTMLElement).classList.contains("readonly");
+
+ it("offers an edit while the control is editable", () => {
+ fixture.detectChanges();
+
+ expect(label()).toBe("Edit code content");
+ expect(greyed()).toBe(false);
+ });
+
+ // The canvas, for a workflow one cannot write: the control is read-only and the button still
+ // opens the editor, which then shows the code without letting it be changed.
+ it("offers a view while the control is read-only and the button still works", () => {
+ component.field.formControl.disable();
+ fixture.detectChanges();
+
+ expect(label()).toBe("View code content");
+ expect(greyed()).toBe(true);
+ });
+
+ // The Form View's read-only inspection of a step: the whole panel is inert, so the button
+ // cannot be pressed at all. It keeps its usual face there, greyed with the rest, rather than
+ // promising a view it cannot open (#8760).
+ it("keeps its usual face, greyed, on a read-only inspection where it cannot be pressed", () => {
+ component.field = { props: { inspect: true }, formControl: new FormControl({ value: "", disabled: true }) };
+ fixture.detectChanges();
+
+ expect(label()).toBe("Edit code content");
+ expect(greyed()).toBe(true);
+ });
+
+ // The inspecting frame disables its form group as it is built, and formly adds the controls to
+ // that group afterwards, so whether this control ends up disabled depends on the order. The
+ // button's face does not follow that race.
+ it("is greyed on an inspection even when its control was left enabled", () => {
+ component.field = { props: { inspect: true }, formControl: new FormControl() };
+ fixture.detectChanges();
+
+ expect(label()).toBe("Edit code content");
+ expect(greyed()).toBe(true);
+ });
+ });
+
/**
* The component's whole job is to keep one shared, per-operator "is the editor open" flag in
* sync across three inputs: this component's own open/close, the same operator's editor opened
@@ -64,13 +112,14 @@ describe("CodeareaCustomTemplateComponent", () => {
* reopen) the wrong operator's editor.
*
* `openEditor` is driven directly rather than through the template button: it calls
- * `codeEditorService.vc.createComponent`, and `vc` is a ViewContainerRef the real workspace
+ * `codeEditorService.host.createComponent`, and the host is a ViewContainerRef the view in front
* supplies. The tests below install a fake one so the component's own bookkeeping is observable
* without standing up a Monaco editor.
*/
describe("editor-open state", () => {
let codeEditorService: CodeEditorService;
let destroyCallbacks: (() => void)[];
+ let editorHost: { createComponent: () => any };
// The highlighted operator is what getOperatorID reads, and it is the key every
// setEditorState/getEditorState call is scoped by.
@@ -103,15 +152,24 @@ describe("CodeareaCustomTemplateComponent", () => {
codeEditorService = TestBed.inject(CodeEditorService);
destroyCallbacks = [];
- // Stand-in for the workspace's ViewContainerRef. Records the onDestroy hook the component
- // registers so the close path can be triggered without a real component teardown.
- codeEditorService.vc = {
- createComponent: () => ({
- instance: {} as any,
- onDestroy: (cb: () => void) => destroyCallbacks.push(cb),
- destroy: () => destroyCallbacks.forEach(cb => cb()),
- }),
- } as any;
+ // Stand-in for a view's editor outlet. Records the inputs the component sets on the editor and
+ // the onDestroy hook it registers, so the close path can be triggered without a real
+ // component teardown.
+ editorHost = {
+ createComponent: () => {
+ const ref = {
+ instance: {} as any,
+ inputs: {} as Record,
+ setInput: (name: string, value: unknown) => {
+ ref.inputs[name] = value;
+ },
+ onDestroy: (cb: () => void) => destroyCallbacks.push(cb),
+ destroy: () => destroyCallbacks.forEach(cb => cb()),
+ };
+ return ref;
+ },
+ };
+ codeEditorService.attachHost(editorHost as any);
});
it("marks the operator's editor open and hands the field's control to it", () => {
@@ -125,6 +183,21 @@ describe("CodeareaCustomTemplateComponent", () => {
expect(publishedEditorState(highlightedOperatorId())).toBe(true);
});
+ // The fake host records whatever name setInput is given; the real ComponentRef would log and
+ // ignore an input the editor does not declare, so the names are checked against its declaration.
+ it("sets only inputs the editor declares", () => {
+ component.openEditor();
+
+ const declared = reflectComponentType(CodeEditorComponent)!.inputs.map(input => input.templateName);
+ expect(declared).toEqual(expect.arrayContaining(Object.keys((component.componentRef as any).inputs)));
+ });
+
+ it("tells the editor its operator, which is the highlighted one", () => {
+ component.openEditor();
+
+ expect((component.componentRef as any).inputs).toEqual({ currentOperatorId: highlightedOperatorId() });
+ });
+
it("clears the flag again when the editor component is destroyed", () => {
component.openEditor();
component.componentRef!.destroy();
@@ -158,6 +231,24 @@ describe("CodeareaCustomTemplateComponent", () => {
expect(remoteComponent.componentRef).toBeDefined();
});
+ // The Form View can show several exposed code cards at once; a shadowed co-editor's open names
+ // one operator, and only that operator's editor opens here (#8760).
+ it("stays closed when a co-editor opens another operator's editor", () => {
+ const opened = new Subject<{ operatorId: string }>();
+ vi.spyOn(TestBed.inject(CoeditorPresenceService), "getCoeditorOpenedCodeEditorSubject").mockReturnValue(
+ opened.asObservable()
+ );
+ const remoteFixture = TestBed.createComponent(CodeareaCustomTemplateComponent);
+ const remoteComponent = remoteFixture.componentInstance;
+ remoteComponent.field = { props: {}, formControl: new FormControl() } as any;
+ remoteFixture.detectChanges();
+
+ opened.next({ operatorId: "another-operator" });
+
+ expect(remoteComponent.isEditorOpen).toBe(false);
+ expect(remoteComponent.componentRef).toBeUndefined();
+ });
+
it("tears this client's editor down when any co-editor closes one, whichever operator it names", () => {
// The mirror image of the open case above, and the half that was missing: a co-editor
// closing the dialog has to close it here too, or this client keeps typing into an editor
@@ -273,5 +364,38 @@ describe("CodeareaCustomTemplateComponent", () => {
codeEditorService.setEditorState(highlightedOperatorId(), false);
expect(component.isEditorOpen).toBe(false);
});
+
+ // Every view that mounts a codearea hosts the editor (#8760), so this is a guard, not a state:
+ // a click, or a co-editor's open, must not throw or render Monaco into nothing.
+ describe("without an editor host", () => {
+ beforeEach(() => {
+ codeEditorService.detachHost(editorHost as any);
+ fixture.detectChanges();
+ });
+
+ it("opens nothing and marks nothing open on a click", () => {
+ expect(() => component.openEditor()).not.toThrow();
+
+ expect(component.isEditorOpen).toBe(false);
+ expect(component.componentRef).toBeUndefined();
+ expect(publishedEditorState(highlightedOperatorId())).toBe(false);
+ });
+
+ it("stays closed when a co-editor opens theirs, instead of throwing", () => {
+ const opened = new Subject<{ operatorId: string }>();
+ vi.spyOn(TestBed.inject(CoeditorPresenceService), "getCoeditorOpenedCodeEditorSubject").mockReturnValue(
+ opened.asObservable()
+ );
+ const remoteFixture = TestBed.createComponent(CodeareaCustomTemplateComponent);
+ const remoteComponent = remoteFixture.componentInstance;
+ remoteComponent.field = { props: {}, formControl: new FormControl() } as any;
+ remoteFixture.detectChanges();
+
+ expect(() => opened.next({ operatorId: highlightedOperatorId() })).not.toThrow();
+
+ expect(remoteComponent.isEditorOpen).toBe(false);
+ expect(remoteComponent.componentRef).toBeUndefined();
+ });
+ });
});
});
diff --git a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.ts b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.ts
index fc852bdfa19..dd92ce6894e 100644
--- a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.ts
+++ b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.ts
@@ -55,6 +55,27 @@ export class CodeareaCustomTemplateComponent extends FieldType
public isEditorOpen: boolean = false;
private operatorID: string = "";
+ /**
+ * Whether the button offers a look at the code rather than an edit: the control is read-only and
+ * the button still works, as on the canvas for a workflow one cannot write. Not on the Form View's
+ * read-only inspection of a step, where the whole panel is inert and nothing in it can be pressed:
+ * there the button keeps its usual face, "Edit code content", greyed out with the rest of the
+ * panel, rather than promising a view it cannot open (#8760).
+ */
+ get viewOnly(): boolean {
+ return this.field.formControl.disabled && !this.inspect;
+ }
+
+ /**
+ * A read-only inspection mount (see viewOnly). The button is greyed out on it whatever its
+ * control's state: the inspecting frame disables its form group as it is built, and formly adds
+ * the controls to that group afterwards, so whether this one ends up disabled depends on the
+ * order -- the button's face should not.
+ */
+ get inspect(): boolean {
+ return this.field.props?.["inspect"] === true;
+ }
+
constructor(
private coeditorPresenceService: CoeditorPresenceService,
private codeEditorService: CodeEditorService,
@@ -62,10 +83,16 @@ export class CodeareaCustomTemplateComponent extends FieldType
private workflowActionService: WorkflowActionService
) {
super();
+ // A shadowed co-editor opening their editor opens the same operator's here, not whatever
+ // operator this codearea happens to be on: only the codearea on that operator answers (#8760).
this.coeditorPresenceService
.getCoeditorOpenedCodeEditorSubject()
.pipe(untilDestroyed(this))
- .subscribe(_ => this.openEditor());
+ .subscribe(({ operatorId }) => {
+ if (operatorId === this.operatorID) {
+ this.openEditor();
+ }
+ });
this.coeditorPresenceService
.getCoeditorClosedCodeEditorSubject()
.pipe(untilDestroyed(this))
@@ -84,7 +111,17 @@ export class CodeareaCustomTemplateComponent extends FieldType
}
openEditor(): void {
- this.componentRef = this.codeEditorService.vc.createComponent(CodeEditorComponent);
+ // Without a host there is nothing to open into. Every view that mounts a codearea hosts the
+ // editor today (the operator canvas and the Form View), so this is a guard, not a state the
+ // button shows: a co-editor's open reaching a codearea with no host must not throw or mark
+ // this operator's editor open.
+ const host = this.codeEditorService.host;
+ if (!host) {
+ return;
+ }
+ this.componentRef = host.createComponent(CodeEditorComponent);
+ // The editor is told its operator by its opener, rather than reading the highlight itself.
+ this.componentRef.setInput("currentOperatorId", this.operatorID);
this.componentRef.instance.componentRef = this.componentRef;
this.componentRef.instance.formControl = this.field.formControl;
this.isEditorOpen = true;
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index fbdf39213cf..728b96fcd65 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -2038,6 +2038,26 @@ describe("OperatorPropertyEditFrameComponent", () => {
});
expect(getField("code")?.type).toBe("codearea");
});
+
+ // The Form View's read-only inspection of a step mounts this frame with actsAsEditor=false and
+ // inert, so its code button cannot be pressed; told so, the button keeps its usual face rather
+ // than offering a view it cannot open (#8760).
+ it("marks the code field as an inspection when not acting as the editor", () => {
+ component.actsAsEditor = false;
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { code: { type: "string", description: "Input your code here" } },
+ });
+ expect(getField("code")?.props?.["inspect"]).toBe(true);
+ });
+
+ it("leaves the code field unmarked when acting as the editor", () => {
+ component.setFormlyFormBinding({
+ type: "object",
+ properties: { code: { type: "string", description: "Input your code here" } },
+ });
+ expect(getField("code")?.props?.["inspect"]).toBeUndefined();
+ });
});
// ──────────────────────────────────────────────────────────────────────────
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index 17101bc598d..4eec26fb0de 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -960,6 +960,13 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
if (customType) {
mappedField.type = customType;
}
+ // A read-only inspection of a step (the Form View outside edit mode mounts this frame with
+ // actsAsEditor=false, and inert) cannot press anything in it, the code button included. The
+ // button is told, so it keeps its usual face there -- "Edit code content", greyed out with the
+ // rest -- instead of offering a view it cannot open (#8760).
+ if (customType === "codearea" && !this.actsAsEditor) {
+ mappedField.props = { ...mappedField.props, inspect: true };
+ }
if (this.currentOperatorSchema?.operatorType === "HuggingFace") {
applyHuggingFaceFieldBindings(mappedField);
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
index e80ade70bae..53c42cc741a 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -590,3 +590,8 @@ Results shown here
+
+
+
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
index 3889c57e24f..dcb3369a2b0 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
@@ -1104,3 +1104,11 @@ $shell: #fafafa;
}
}
}
+
+/* The code editor opens into this page's outlet (texeraCodeEditorHost), so it is a descendant and
+ can be dragged under the sticky title row (z-index 20) and the panel's close button; keep the
+ editor above them. The canvas's chrome never exceeds that, so the shared editor keeps its own
+ value and only this page lifts it. The class is what beats the editor's own rule in specificity. */
+:host ::ng-deep #code-editor-container.box {
+ z-index: 21;
+}
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
index 610488e05aa..54663d2b9cb 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -63,6 +63,7 @@ import { ExecutionState, ExecutionStateInfo } from "../../types/execute-workflow
import { OperatorPredicate, Point } from "../../types/workflow-common.interface";
import { ComputingUnitSelectionComponent } from "../power-button/computing-unit-selection.component";
import { PropertyEditorComponent } from "../property-editor/property-editor.component";
+import { CodeEditorHostDirective } from "../code-editor-dialog/code-editor-host.directive";
import { ResultTableFrameComponent } from "../result-panel/result-table-frame/result-table-frame.component";
import { VisualizationFrameContentComponent } from "../visualization-panel-content/visualization-frame-content.component";
import { WorkflowEditorComponent } from "../workflow-editor/workflow-editor.component";
@@ -169,6 +170,7 @@ interface ResultChoice {
MiniMapComponent,
CoeditorUserIconComponent,
ReadOnlyCardDirective,
+ CodeEditorHostDirective,
],
})
export class WorkflowFormComponent implements OnInit, OnDestroy {
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
index 07b0048102c..821728c3765 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
@@ -72,6 +72,7 @@ import { WorkflowPveService } from "../../service/virtual-environment/virtual-en
import { NzModalService } from "ng-zorro-antd/modal";
import { ExecutionState } from "../../types/execute-workflow.interface";
import { GuiConfigService } from "../../../common/service/gui-config.service";
+import { CodeEditorService } from "../../service/code-editor/code-editor.service";
/**
* The direct-construction spec exercises the component's logic without a DOM; this one stands
@@ -815,6 +816,19 @@ describe("WorkflowFormComponent (rendered template)", () => {
expect(el(".result .result-body")).not.toBeNull();
});
+ // The page hosts the code editor while it is in front (#8760): the step panel's code button and
+ // an exposed code property's open the editor here, and leaving the page closes it.
+ it("hosts the code editor while the page is up, and lets go of the host when it is destroyed", () => {
+ const codeEditorService = TestBed.inject(CodeEditorService);
+ fixture.detectChanges();
+ finishLoad();
+ expect(codeEditorService.host).toBeDefined();
+
+ fixture.destroy();
+
+ expect(codeEditorService.host).toBeUndefined();
+ });
+
it("mounts the step panel from the start, hidden until a step is selected", () => {
fixture.detectChanges();
finishLoad();
diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
index 0bce6e34ff9..4476175853b 100644
--- a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
+++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
@@ -139,6 +139,7 @@ export function setupHarness() {
setHeatmapView: vi.fn(),
};
const workflowActionService = {
+ setOperatorProperty: vi.fn(),
resetAsNewWorkflow: vi.fn(),
setNewSharedModel: vi.fn(),
reloadWorkflow: vi.fn(),
diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html
index c83432238d2..2d42b90adb3 100644
--- a/frontend/src/app/workspace/component/workspace.component.html
+++ b/frontend/src/app/workspace/component/workspace.component.html
@@ -36,4 +36,5 @@
[agentIdToActivate]="agentIdToActivate">
-
+
+
diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts b/frontend/src/app/workspace/component/workspace.component.spec.ts
index 118fc9e4118..db49d9783df 100644
--- a/frontend/src/app/workspace/component/workspace.component.spec.ts
+++ b/frontend/src/app/workspace/component/workspace.component.spec.ts
@@ -36,6 +36,7 @@ import { UserService } from "../../common/service/user/user.service";
import { WorkflowPersistService } from "../../common/service/workflow-persist/workflow-persist.service";
import { Workflow } from "../../common/type/workflow";
import { CodeEditorService } from "../service/code-editor/code-editor.service";
+import { CodeEditorHostDirective } from "./code-editor-dialog/code-editor-host.directive";
import { WorkflowCompilingService } from "../service/compile-workflow/workflow-compiling.service";
import { OperatorMetadataService } from "../service/operator-metadata/operator-metadata.service";
import { UndoRedoService } from "../service/undo-redo/undo-redo.service";
@@ -148,7 +149,7 @@ describe("WorkspaceComponent", () => {
notificationService = { error: vi.fn() };
hubService = { postView: vi.fn().mockReturnValue(of(0)) };
- codeEditorService = { vc: undefined };
+ codeEditorService = { attachHost: vi.fn(), detachHost: vi.fn() };
messageService = { error: vi.fn() };
// `getCurrentNavigation` answers what the page is being destroyed for: null stands for no
@@ -174,15 +175,15 @@ describe("WorkspaceComponent", () => {
workflowConsoleService = { clearConsoleMessages: vi.fn() };
workflowResultService = { clearResults: vi.fn() };
- // Drop the standalone component's child imports and allow unknown elements via
- // CUSTOM_ELEMENTS_SCHEMA. The template still renders, so ``
- // is wired up and the @ViewChild query resolves to a real ViewContainerRef, while
+ // Drop the standalone component's child imports -- all but the code editor host directive, a
+ // leaf with no children of its own -- and allow unknown elements via CUSTOM_ELEMENTS_SCHEMA. The
+ // template still renders, so the `texeraCodeEditorHost` outlet is built and attached, while
// the children's transitive dependencies stay out of the test build.
// TODO(coverage): rewrite using stub child components via remove/add so the
// template participates in coverage. See TESTING.md anti-pattern #9.
/* eslint-disable no-restricted-syntax */
TestBed.overrideComponent(WorkspaceComponent, {
- set: { imports: [], providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA] },
+ set: { imports: [CodeEditorHostDirective], providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA] },
});
/* eslint-enable no-restricted-syntax */
@@ -216,12 +217,6 @@ describe("WorkspaceComponent", () => {
fixture = TestBed.createComponent(WorkspaceComponent);
component = fixture.componentInstance;
- // ngOnDestroy clears the ViewContainerRef bound to `#codeEditor`. Tests that
- // exercise individual methods skip change detection, so the @ViewChild query
- // is never resolved; assign a stub to keep TestBed teardown from throwing.
- // Tests that exercise `fixture.detectChanges()` will overwrite this with
- // the live ViewContainerRef during ngAfterViewInit.
- component.codeEditorViewRef = { clear: vi.fn() } as any;
}
describe("ngOnInit", () => {
@@ -676,19 +671,23 @@ describe("WorkspaceComponent", () => {
});
});
- // Exercises the rendered template: the `` outlet is
- // present, so the @ViewChild query resolves to a live ViewContainerRef and
- // ngAfterViewInit can publish it to CodeEditorService.
- describe("child rendering side effects", () => {
- it("publishes the resolved ViewContainerRef to CodeEditorService.vc on view init", async () => {
- codeEditorService.vc = undefined;
+ // Exercises the rendered template: the `texeraCodeEditorHost` outlet is in it, so the canvas
+ // hosts the code editor while it is up and lets go of the host when it is destroyed. The Form
+ // View carries the same outlet, which is how the host changes hands between the two (#8760).
+ describe("the code editor host", () => {
+ it("is attached when the canvas renders, and detached when the canvas is destroyed", async () => {
await createFixture();
fixture.detectChanges();
- // createEmbeddedView is present on a real ViewContainerRef but not on the
- // pre-fixture stub, so checking it distinguishes the resolved query from
- // the placeholder.
- expect(codeEditorService.vc).toBe(component.codeEditorViewRef);
- expect(typeof codeEditorService.vc.createEmbeddedView).toBe("function");
+ expect(codeEditorService.attachHost).toHaveBeenCalledTimes(1);
+ // A real ViewContainerRef (the directive's own), not a placeholder: the editor is created
+ // into it.
+ const host = codeEditorService.attachHost.mock.calls[0][0];
+ expect(typeof host.createComponent).toBe("function");
+ expect(codeEditorService.detachHost).not.toHaveBeenCalled();
+
+ fixture.destroy();
+
+ expect(codeEditorService.detachHost).toHaveBeenCalledWith(host);
});
});
});
diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts
index 3e43c07acd9..2adbda18ce4 100644
--- a/frontend/src/app/workspace/component/workspace.component.ts
+++ b/frontend/src/app/workspace/component/workspace.component.ts
@@ -18,17 +18,7 @@
*/
import { Location, NgIf } from "@angular/common";
-import {
- AfterViewInit,
- ChangeDetectorRef,
- Component,
- HostListener,
- Input,
- OnDestroy,
- OnInit,
- ViewChild,
- ViewContainerRef,
-} from "@angular/core";
+import { AfterViewInit, ChangeDetectorRef, Component, HostListener, Input, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { UserService } from "../../common/service/user/user.service";
import { WorkflowPersistService } from "../../common/service/workflow-persist/workflow-persist.service";
@@ -44,7 +34,7 @@ import { isDefined } from "../../common/util/predicate";
import { NotificationService } from "src/app/common/service/notification/notification.service";
import { WorkflowConsoleService } from "../service/workflow-console/workflow-console.service";
import { OperatorReuseCacheStatusService } from "../service/workflow-status/operator-reuse-cache-status.service";
-import { CodeEditorService } from "../service/code-editor/code-editor.service";
+import { CodeEditorHostDirective } from "./code-editor-dialog/code-editor-host.directive";
import { WorkflowMetadata } from "src/app/dashboard/type/workflow-metadata.interface";
import { EntityType, HubService } from "../../hub/service/hub.service";
import { THROTTLE_TIME_MS } from "../../hub/component/workflow/detail/hub-workflow-detail.component";
@@ -88,12 +78,12 @@ export const SAVE_DEBOUNCE_TIME_IN_MS = 5000;
AgentPanelComponent,
PropertyEditorComponent,
JupyterNotebookPanelComponent,
+ CodeEditorHostDirective,
],
})
export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy {
public writeAccess: boolean = false;
public isLoading: boolean = false;
- @ViewChild("codeEditor", { read: ViewContainerRef }) codeEditorViewRef!: ViewContainerRef;
/**
* Optional agent ID to activate when the workspace loads.
@@ -134,7 +124,6 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy {
private router: Router,
private notificationService: NotificationService,
private hubService: HubService,
- private codeEditorService: CodeEditorService,
private config: GuiConfigService,
private changeDetectorRef: ChangeDetectorRef,
private computingUnitStatusService: ComputingUnitStatusService,
@@ -185,7 +174,6 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy {
this.onWIDChange();
this.updateViewCount();
this.registerLoadOperatorMetadata();
- this.codeEditorService.vc = this.codeEditorViewRef;
}
/**
@@ -210,7 +198,6 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy {
ngOnDestroy() {
this.persistBeforeLeaving();
- this.codeEditorViewRef.clear();
// Tear down the connection and all websocket-derived session state so a
// re-entered workflow starts clean instead of reusing the previous one -- unless the Form
// View of this same workflow is taking over, in which case the session is handed to it
diff --git a/frontend/src/app/workspace/service/code-editor/code-editor.service.spec.ts b/frontend/src/app/workspace/service/code-editor/code-editor.service.spec.ts
index 6f514b46c12..1dc53664715 100644
--- a/frontend/src/app/workspace/service/code-editor/code-editor.service.spec.ts
+++ b/frontend/src/app/workspace/service/code-editor/code-editor.service.spec.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { ViewContainerRef } from "@angular/core";
import { CodeEditorService } from "./code-editor.service";
describe("CodeEditorService", () => {
@@ -70,4 +71,32 @@ describe("CodeEditorService", () => {
expect(valueA).toBe(true);
expect(valueB).toBe(false);
});
+
+ // The outlet the editor opens into, attached by whichever view is in front (#8760).
+ describe("the editor host", () => {
+ const outlet = (name: string) => ({ name }) as unknown as ViewContainerRef;
+
+ it("has no host until a view attaches one", () => {
+ expect(service.host).toBeUndefined();
+
+ const canvas = outlet("canvas");
+ service.attachHost(canvas);
+
+ expect(service.host).toBe(canvas);
+ });
+
+ it("lets go of the host that detaches, and keeps one that has replaced it", () => {
+ const canvas = outlet("canvas");
+ const form = outlet("form");
+ service.attachHost(canvas);
+ service.attachHost(form);
+
+ // The canvas's late destroy must not take the form's host with it.
+ service.detachHost(canvas);
+ expect(service.host).toBe(form);
+
+ service.detachHost(form);
+ expect(service.host).toBeUndefined();
+ });
+ });
});
diff --git a/frontend/src/app/workspace/service/code-editor/code-editor.service.ts b/frontend/src/app/workspace/service/code-editor/code-editor.service.ts
index 8dd534e60dc..8fb0dcd5bda 100644
--- a/frontend/src/app/workspace/service/code-editor/code-editor.service.ts
+++ b/frontend/src/app/workspace/service/code-editor/code-editor.service.ts
@@ -24,7 +24,30 @@ import { BehaviorSubject, Observable } from "rxjs";
providedIn: "root",
})
export class CodeEditorService {
- public vc!: ViewContainerRef;
+ /**
+ * Where the code editor renders: the outlet of the view in front, marked `texeraCodeEditorHost`
+ * on an `` in its template (see CodeEditorHostDirective). The operator canvas and the
+ * Form View each carry one; a view's outlet is attached when its template is built and detached
+ * when the view is destroyed. The editor is a floating box, so where the outlet sits in the page
+ * does not matter -- only that the view in front has one.
+ */
+ private editorHost?: ViewContainerRef;
+
+ /** The outlet to open the editor into, while a view hosts one. */
+ get host(): ViewContainerRef | undefined {
+ return this.editorHost;
+ }
+
+ attachHost(host: ViewContainerRef): void {
+ this.editorHost = host;
+ }
+
+ /** Lets go of this host only: a host that has since replaced it stays attached. */
+ detachHost(host: ViewContainerRef): void {
+ if (this.editorHost === host) {
+ this.editorHost = undefined;
+ }
+ }
private editorStates: Map> = new Map();