diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 78fc75d7cbc..db907f7745f 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -20,7 +20,7 @@
import { DatePipe, registerLocaleData } from "@angular/common";
import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http";
import en from "@angular/common/locales/en";
-import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, NgModule } from "@angular/core";
+import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, APP_BOOTSTRAP_LISTENER, NgModule } from "@angular/core";
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { BrowserModule } from "@angular/platform-browser";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
@@ -191,6 +191,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox";
import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component";
import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component";
import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component";
+import { JupyterPanelService } from "./workspace/service/jupyter-panel/jupyter-panel.service";
registerLocaleData(en);
@@ -404,6 +405,12 @@ registerLocaleData(en);
deps: [GuiConfigService],
multi: true,
},
+ {
+ provide: APP_BOOTSTRAP_LISTENER,
+ useFactory: (jupyterPanelService: JupyterPanelService) => () => jupyterPanelService.init(),
+ deps: [JupyterPanelService],
+ multi: true,
+ },
],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html
index adbf9f6ab5c..df1d86f6386 100644
--- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html
+++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.html
@@ -53,6 +53,17 @@
nz-icon
nzType="zoom-in">
+
diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss
index c4d9667dc86..293dae89316 100644
--- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss
+++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.scss
@@ -45,6 +45,13 @@
z-index: 4;
}
+#minimap-expand-jupyter-button {
+ position: absolute;
+ bottom: 0;
+ right: 120px;
+ z-index: 4;
+}
+
#mini-map-container {
position: relative;
overflow: hidden;
diff --git a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts
index 447ab8d1f68..305f37503ca 100644
--- a/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/mini-map/mini-map.component.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, HostListener, OnDestroy, ViewChild } from "@angular/core";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service";
@@ -31,6 +32,8 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave";
import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
import { NzIconDirective } from "ng-zorro-antd/icon";
import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/repeat-dnd.component";
+import { JupyterPanelService } from "../../../service/jupyter-panel/jupyter-panel.service";
+import { GuiConfigService } from "../../../../common/service/gui-config.service";
@UntilDestroy()
@Component({
@@ -45,6 +48,7 @@ import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/r
NzIconDirective,
CdkDrag,
FormlyRepeatDndComponent,
+ CommonModule,
],
})
export class MiniMapComponent implements AfterViewInit, OnDestroy {
@@ -57,7 +61,9 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy {
constructor(
private workflowActionService: WorkflowActionService,
- private panelService: PanelService
+ private panelService: PanelService,
+ protected config: GuiConfigService,
+ private jupyterPanelService: JupyterPanelService
) {}
ngAfterViewInit() {
@@ -156,6 +162,17 @@ export class MiniMapComponent implements AfterViewInit, OnDestroy {
);
}
+ /**
+ * This method will expand and redisplay the jupyter notebook.
+ */
+ public get pythonNotebookMigrationEnabled(): boolean {
+ return this.config.env.pythonNotebookMigrationEnabled;
+ }
+
+ public onClickExpandJupyterNotebookPanel(): void {
+ this.jupyterPanelService.openJupyterNotebookPanel();
+ }
+
public triggerCenter(): void {
this.workflowActionService.getTexeraGraph().triggerCenterEvent();
if (this.navigatorDrag) this.navigatorDrag.reset();
diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
index 979f131ad3c..8d07eebaf71 100644
--- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
@@ -48,6 +48,7 @@ import { NzNoAnimationDirective } from "ng-zorro-antd/core/animation";
import { ContextMenuComponent } from "./context-menu/context-menu/context-menu.component";
import { NgIf } from "@angular/common";
import { AgentInteractionComponent } from "../agent/agent-interaction/agent-interaction.component";
+import { JupyterPanelService } from "../../service/jupyter-panel/jupyter-panel.service";
// jointjs interactive options for enabling and disabling interactivity
// https://resources.jointjs.com/docs/jointjs/v3.2/joint.html#dia.Paper.prototype.options.interactive
@@ -128,7 +129,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
public nzContextMenu: NzContextMenuService,
private elementRef: ElementRef,
private config: GuiConfigService,
- private agentService: AgentService
+ private agentService: AgentService,
+ private jupyterPanelService: JupyterPanelService
) {
this.wrapper = this.workflowActionService.getJointGraphWrapper();
}
@@ -603,12 +605,14 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
const elementID = event[0].model.id.toString();
const highlightedOperatorIDs = this.wrapper.getCurrentHighlightedOperatorIDs();
const highlightedCommentBoxIDs = this.wrapper.getCurrentHighlightedCommentBoxIDs();
+ this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell
if (event[1].shiftKey) {
// if in multiselect toggle highlights on click
if (highlightedOperatorIDs.includes(elementID)) {
this.workflowActionService.unhighlightOperators(elementID);
} else if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) {
this.workflowActionService.highlightOperators(event[1].shiftKey, elementID);
+ this.jupyterPanelService.onWorkflowComponentClick(elementID); // Highlight corresponding Jupyter notebook cell
}
if (highlightedCommentBoxIDs.includes(elementID)) {
this.wrapper.unhighlightCommentBoxes(elementID);
diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts
new file mode 100644
index 00000000000..20a5c6793a4
--- /dev/null
+++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts
@@ -0,0 +1,275 @@
+/**
+ * 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 { TestBed } from "@angular/core/testing";
+import { JupyterPanelService } from "./jupyter-panel.service";
+import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { NotificationService } from "src/app/common/service/notification/notification.service";
+import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service";
+import { GuiConfigService } from "src/app/common/service/gui-config.service";
+import { of } from "rxjs";
+
+describe("JupyterPanelService", () => {
+ let service: JupyterPanelService;
+ let httpMock: HttpTestingController;
+
+ let mockWorkflow: any;
+ let mockNotification: any;
+ let mockNotebook: any;
+ // Mutable so individual describe blocks can flip the flag mid-spec; the
+ // service stores a reference, so mutations are observed on the next read.
+ let mockGuiConfig: { env: { pythonNotebookMigrationEnabled: boolean } };
+
+ beforeEach(() => {
+ mockWorkflow = {
+ workflowMetaDataChanged: jasmine.createSpy().and.returnValue(of({ wid: 1 })),
+ getWorkflow: jasmine.createSpy().and.returnValue({ wid: 1 }),
+ getTexeraGraph: jasmine.createSpy().and.returnValue({
+ getAllLinks: () => [
+ {
+ linkID: "L1",
+ source: { operatorID: "A" },
+ target: { operatorID: "B" },
+ },
+ ],
+ getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }],
+ }),
+ highlightOperators: jasmine.createSpy(),
+ highlightLinks: jasmine.createSpy(),
+ unhighlightOperators: jasmine.createSpy(),
+ unhighlightLinks: jasmine.createSpy(),
+ };
+
+ mockNotification = {
+ warning: jasmine.createSpy(),
+ };
+
+ mockNotebook = {
+ hasMapping: jasmine.createSpy().and.returnValue(true),
+ getMapping: jasmine.createSpy().and.returnValue({
+ cell_to_operator: {
+ cell1: ["A", "B"],
+ },
+ operator_to_cell: {},
+ }),
+ deleteMapping: jasmine.createSpy(),
+ setMapping: jasmine.createSpy(),
+ getJupyterURL: jasmine.createSpy().and.resolveTo("http://jupyter"),
+ };
+
+ mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } };
+
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [
+ JupyterPanelService,
+ { provide: WorkflowActionService, useValue: mockWorkflow },
+ { provide: NotificationService, useValue: mockNotification },
+ { provide: NotebookMigrationService, useValue: mockNotebook },
+ { provide: GuiConfigService, useValue: mockGuiConfig },
+ ],
+ });
+
+ service = TestBed.inject(JupyterPanelService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ // Panel visibility
+ it("should open and close panel", () => {
+ let state: boolean | null = null;
+
+ service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
+
+ service.openPanel("JupyterNotebookPanel");
+ expect(state).toBeTrue();
+
+ service.closeJupyterNotebookPanel();
+ expect(state).toBeFalse();
+ });
+
+ it("should minimize panel", () => {
+ let state: boolean | null = true;
+
+ service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
+
+ service.minimizeJupyterNotebookPanel();
+
+ expect(state).toBeFalse();
+ });
+
+ // openJupyterNotebookPanel
+ it("should warn if no mapping exists", () => {
+ mockNotebook.hasMapping.and.returnValue(false);
+
+ service.openJupyterNotebookPanel();
+
+ expect(mockNotification.warning).toHaveBeenCalled();
+ });
+
+ it("should open panel if mapping exists", () => {
+ mockNotebook.hasMapping.and.returnValue(true);
+
+ let state: boolean | null = false;
+
+ service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
+
+ service.openJupyterNotebookPanel();
+
+ expect(state).toBeTrue();
+ });
+
+ // openPanel
+ it("should open panel only for correct name", () => {
+ let state: boolean | null = false;
+
+ service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
+
+ service.openPanel("WrongPanel");
+ expect(state).toBeFalse();
+
+ service.openPanel("JupyterNotebookPanel");
+ expect(state).toBeTrue();
+ });
+
+ // HTTP fetchNotebookAndMapping
+ it("should return 0 when exists=false", done => {
+ (service as any).fetchNotebookAndMapping(1, 1).subscribe((result: any) => {
+ expect(result).toBe(0);
+ done();
+ });
+
+ const req = httpMock.expectOne(r => r.url.includes("/notebook-migration/fetch-notebook-and-mapping"));
+
+ req.flush({ exists: false });
+ });
+
+ // iframe ref
+ it("should store iframe reference", () => {
+ const iframe = document.createElement("iframe");
+
+ service.setIframeRef(iframe);
+
+ expect((service as any).iframeRef).toBe(iframe);
+ });
+
+ // highlightFromCell
+ it("should highlight operators and links", () => {
+ (service as any).cellToHighlightMapping = {
+ cell1: {
+ components: ["op1", "op2"],
+ edges: ["link1"],
+ },
+ };
+
+ const method = (service as any).highlightFromCell.bind(service);
+
+ method("cell1");
+
+ expect(mockWorkflow.unhighlightOperators).toHaveBeenCalled();
+ expect(mockWorkflow.unhighlightLinks).toHaveBeenCalled();
+ expect(mockWorkflow.highlightOperators).toHaveBeenCalledWith(true, "op1", "op2");
+ expect(mockWorkflow.highlightLinks).toHaveBeenCalledWith(true, "link1");
+ });
+
+ // onWorkflowComponentClick
+ it("should postMessage when mapping exists", async () => {
+ const mockIframe = {
+ contentWindow: {
+ postMessage: jasmine.createSpy(),
+ },
+ } as any;
+
+ service.setIframeRef(mockIframe);
+ (mockNotebook as any).getMapping.and.returnValue({
+ cell_to_operator: {},
+ operator_to_cell: {
+ cell1: ["op1", "op2"],
+ },
+ });
+
+ await service.onWorkflowComponentClick("cell1");
+
+ expect(mockIframe.contentWindow.postMessage).toHaveBeenCalledWith(
+ {
+ action: "triggerCellClick",
+ operators: ["op1", "op2"],
+ },
+ "http://jupyter"
+ );
+ });
+
+ // Feature flag gate (defence in depth). With the flag off, every public
+ // method must short-circuit — no subscription in init, no visibility flips,
+ // no postMessage. The window message listener is installed in the constructor
+ // unconditionally, but handleNotebookMessage returns early on the flag check.
+ describe("when the feature flag is disabled", () => {
+ beforeEach(() => {
+ mockGuiConfig.env.pythonNotebookMigrationEnabled = false;
+ });
+
+ it("init does not subscribe to workflowMetaDataChanged", () => {
+ service.init();
+ expect(mockWorkflow.workflowMetaDataChanged).not.toHaveBeenCalled();
+ });
+
+ it("openPanel does not flip the visibility stream", () => {
+ let state: boolean | null = false;
+ service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
+ service.openPanel("JupyterNotebookPanel");
+ expect(state).toBeFalse();
+ });
+
+ it("closeJupyterNotebookPanel does not flip visibility or delete the mapping", () => {
+ // BehaviorSubject's initial value is false; the meaningful assertion is
+ // that the side effect (deleteMapping) was never called.
+ service.closeJupyterNotebookPanel();
+ expect(mockNotebook.deleteMapping).not.toHaveBeenCalled();
+ });
+
+ it("minimizeJupyterNotebookPanel does not flip visibility", () => {
+ const visibleSubject = (service as any).jupyterNotebookPanelVisible;
+ visibleSubject.next(true);
+ service.minimizeJupyterNotebookPanel();
+ expect(visibleSubject.value).toBeTrue();
+ });
+
+ it("openJupyterNotebookPanel does not warn or flip visibility", () => {
+ mockNotebook.hasMapping.and.returnValue(false);
+ let state: boolean | null = false;
+ service.jupyterNotebookPanelVisible$.subscribe(v => (state = v));
+ service.openJupyterNotebookPanel();
+ expect(state).toBeFalse();
+ expect(mockNotification.warning).not.toHaveBeenCalled();
+ });
+
+ it("onWorkflowComponentClick does not postMessage to the iframe", async () => {
+ const mockIframe = {
+ contentWindow: { postMessage: jasmine.createSpy() },
+ } as any;
+ service.setIframeRef(mockIframe);
+ await service.onWorkflowComponentClick("cell1");
+ expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts
new file mode 100644
index 00000000000..b5f704c1397
--- /dev/null
+++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts
@@ -0,0 +1,276 @@
+/**
+ * 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 { Injectable } from "@angular/core";
+import { BehaviorSubject, catchError, map, of } from "rxjs";
+import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
+import { OperatorLink } from "../../types/workflow-common.interface";
+import { HttpClient, HttpHeaders } from "@angular/common/http";
+import { UntilDestroy } from "@ngneat/until-destroy";
+import { NotificationService } from "src/app/common/service/notification/notification.service";
+import { distinctUntilChanged, switchMap } from "rxjs/operators";
+import { AppSettings } from "../../../common/app-setting";
+import { NotebookMigrationService } from "../notebook-migration/notebook-migration.service";
+import { GuiConfigService } from "../../../common/service/gui-config.service";
+
+@UntilDestroy()
+@Injectable({
+ providedIn: "root",
+})
+export class JupyterPanelService {
+ private jupyterNotebookPanelVisible = new BehaviorSubject(false);
+ public jupyterNotebookPanelVisible$ = this.jupyterNotebookPanelVisible.asObservable();
+
+ private iframeRef: HTMLIFrameElement | null = null; // Store reference to iframe element
+ private cellContent: string[] = []; // Store the content of the cells
+ private highlightedCell: number | null = null; // Track the highlighted cell
+
+ // Precomputed dictionary for cell to highlight mapping
+ private cellToHighlightMapping: Record = {};
+
+ constructor(
+ private workflowActionService: WorkflowActionService,
+ private http: HttpClient,
+ private notificationService: NotificationService,
+ private notebookMigrationService: NotebookMigrationService,
+ private config: GuiConfigService
+ ) {
+ window.addEventListener("message", this.handleNotebookMessage);
+ }
+
+ private get enabled(): boolean {
+ return this.config.env.pythonNotebookMigrationEnabled;
+ }
+
+ public init(): void {
+ if (!this.enabled) return;
+ this.workflowActionService
+ .workflowMetaDataChanged()
+ .pipe(
+ map(meta => meta.wid),
+ distinctUntilChanged()
+ )
+ .subscribe(wid => {
+ this.closeJupyterNotebookPanel();
+ if (wid != 0) {
+ this.fetchNotebookAndMapping(wid).subscribe(result => {
+ if (result == 1) {
+ this.precomputeHighlightMapping();
+ this.openJupyterNotebookPanel();
+ }
+ });
+ }
+ });
+ }
+
+ private fetchNotebookAndMapping(
+ workflowID: number | undefined = this.workflowActionService.getWorkflow().wid,
+ vId: number = 1
+ ) {
+ // Fetch mapping and notebook from migration database if exists for wid
+ const dbAPIUrl = `${AppSettings.getApiEndpoint()}/notebook-migration/fetch-notebook-and-mapping`;
+ const headers = new HttpHeaders({ "Content-Type": "application/json" });
+ const payload = {
+ wid: workflowID,
+ vid: vId, // Future work: add dynamic fetching of current workflow vId
+ };
+
+ return this.http.post(dbAPIUrl, payload, { headers }).pipe(
+ switchMap(async (response: any) => {
+ // Only load mapping and workflow if they exist
+ if (response.exists) {
+ this.notebookMigrationService.setMapping("mapping_wid_" + workflowID, response.mapping);
+
+ if ((await this.notebookMigrationService.sendNotebookToJupyter(response.notebook)) == 1) {
+ return 1;
+ } else {
+ return 0;
+ }
+ } else {
+ return 0;
+ }
+ }),
+ catchError((error: unknown) => {
+ console.error("Network response was not ok when fetching notebook and mapping:", error);
+ return of(0);
+ })
+ );
+ }
+
+ // Precompute the dictionary for O(1) highlighting
+ private precomputeHighlightMapping(): void {
+ const wid = this.workflowActionService.getWorkflow().wid;
+
+ if (wid === undefined) {
+ console.warn("Workflow ID is undefined. Cannot compute highlight mapping.");
+ return;
+ }
+ const mappingKey = "mapping_wid_" + wid;
+ const mapping = this.notebookMigrationService.getMapping(mappingKey);
+
+ if (mapping == undefined) {
+ console.warn(`Mapping key '${mappingKey}' not found. Cannot compute highlight mapping.`);
+ return;
+ }
+ const cellToOperator = mapping.cell_to_operator;
+
+ const allLinks: OperatorLink[] = this.workflowActionService.getTexeraGraph().getAllLinks();
+ if (allLinks.length === 0) {
+ console.warn("No links found in the graph during precompute.");
+ return;
+ }
+
+ for (const cellUUID in cellToOperator) {
+ const components = cellToOperator[cellUUID] || [];
+ const componentSet = new Set(components);
+ const edges: string[] = [];
+
+ allLinks.forEach(link => {
+ const sourceOperatorID = link.source.operatorID;
+ const targetOperatorID = link.target.operatorID;
+
+ if (
+ componentSet.has(sourceOperatorID) &&
+ componentSet.has(targetOperatorID) &&
+ sourceOperatorID !== targetOperatorID
+ ) {
+ edges.push(link.linkID);
+ }
+ });
+
+ this.cellToHighlightMapping[cellUUID] = { components, edges };
+ }
+ }
+
+ // Set the iframe reference (from the component's ViewChild)
+ setIframeRef(iframe: HTMLIFrameElement) {
+ this.iframeRef = iframe;
+ }
+
+ // Open the Jupyter Notebook panel
+ openPanel(panelName: string): void {
+ if (!this.enabled) return;
+ if (panelName === "JupyterNotebookPanel") {
+ this.jupyterNotebookPanelVisible.next(true);
+ }
+ }
+
+ // Close the Jupyter Notebook panel
+ closeJupyterNotebookPanel(): void {
+ if (!this.enabled) return;
+ this.jupyterNotebookPanelVisible.next(false);
+ const wid = this.workflowActionService.getWorkflow().wid;
+ if (wid != undefined) {
+ this.notebookMigrationService.deleteMapping("mapping_wid_" + wid);
+ }
+ }
+
+ // Minimize the Jupyter Notebook panel
+ public minimizeJupyterNotebookPanel(): void {
+ if (!this.enabled) return;
+ this.jupyterNotebookPanelVisible.next(false);
+ }
+
+ // Expand the Jupyter Notebook panel
+ public openJupyterNotebookPanel(): void {
+ if (!this.enabled) return;
+ const wid = this.workflowActionService.getWorkflow().wid;
+ const mappingKey = "mapping_wid_" + wid;
+ // Check if there is corresponding mapping data
+ if (wid === undefined || !this.notebookMigrationService.hasMapping(mappingKey)) {
+ this.notificationService.warning("No Jupyter notebook associated with this workflow.");
+ return;
+ }
+
+ // Expand only if the mapping exists
+ this.jupyterNotebookPanelVisible.next(true);
+ }
+
+ // Handle messages from the Jupyter notebook iframe
+ private handleNotebookMessage = async (event: MessageEvent) => {
+ if (!this.enabled) return;
+ const allowedOrigins = [window.location.origin, await this.notebookMigrationService.getJupyterURL()];
+ if (!allowedOrigins.includes(event.origin)) {
+ return;
+ }
+
+ const { action, cellIndex, cellContent, cellUUID } = event.data;
+ if (action === "cellClicked") {
+ this.highlightedCell = cellIndex;
+ this.cellContent[cellIndex] = cellContent || `Cell ${cellIndex + 1}`;
+ this.highlightFromCell(cellUUID);
+ }
+ };
+
+ // Highlight operators and edges based on the clicked cell
+ private highlightFromCell(cellUUID: string): void {
+ const highlightData = this.cellToHighlightMapping[cellUUID] || { components: [], edges: [] };
+
+ // Unhighlight all operators and links
+ this.workflowActionService.unhighlightOperators(
+ ...this.workflowActionService
+ .getTexeraGraph()
+ .getAllOperators()
+ .map(op => op.operatorID)
+ );
+ this.workflowActionService.unhighlightLinks(
+ ...this.workflowActionService
+ .getTexeraGraph()
+ .getAllLinks()
+ .map(link => link.linkID)
+ );
+
+ // Highlight components and edges
+ if (highlightData.components.length > 0) {
+ this.workflowActionService.highlightOperators(true, ...highlightData.components);
+ }
+ if (highlightData.edges.length > 0) {
+ this.workflowActionService.highlightLinks(true, ...highlightData.edges);
+ }
+ }
+
+ // Handle when a Texera component is clicked to trigger the corresponding notebook cell
+ async onWorkflowComponentClick(cellUUID: string): Promise {
+ if (!this.enabled) return;
+ const jupyterURL = await this.notebookMigrationService.getJupyterURL();
+ if (jupyterURL && this.iframeRef && this.iframeRef.contentWindow) {
+ const wid = this.workflowActionService.getWorkflow().wid;
+
+ if (wid == undefined) {
+ console.error("Error fetching wid of current workflow");
+ return;
+ }
+
+ const mappingKey = "mapping_wid_" + wid;
+ const mappingEntry = this.notebookMigrationService.getMapping(mappingKey);
+
+ if (!mappingEntry) {
+ console.error("Missing mapping for workflow:", mappingKey);
+ return;
+ }
+
+ const operatorArray = mappingEntry["operator_to_cell"][cellUUID];
+ if (operatorArray) {
+ this.iframeRef.contentWindow.postMessage({ action: "triggerCellClick", operators: operatorArray }, jupyterURL);
+ } else {
+ console.error(`No operators found for cellUUID: ${cellUUID}`);
+ }
+ }
+ }
+}