This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new fa075d1154 feat(python-notebook-migration): add Jupyter panel mapping 
highlight service (#5263)
fa075d1154 is described below

commit fa075d11541a3282562ed6ade743548cb8586d1b
Author: Ryan Zhang <[email protected]>
AuthorDate: Wed Jul 22 15:07:41 2026 -0700

    feat(python-notebook-migration): add Jupyter panel mapping highlight 
service (#5263)
    
    ### What changes were proposed in this PR?
    Introduces `JupyterPanelService`, which carries the bidirectional cell
    to operator highlight orchestration, plus the workflow editor hook that
    calls into it on operator click.
    
    **`JupyterPanelService`**
    - `init()`: subscribes to `workflowMetaDataChanged()` and is registered
    as an `APP_BOOTSTRAP_LISTENER` in `app.module.ts` so it starts on app
    bootstrap. On each workflow change it drops any stale mapping for the
    current workflow (inline `deleteMapping("mapping_wid_" + currentWid)`),
    fetches the new workflow's stored notebook and mapping from the
    microservice, sends the notebook to JupyterLab, and precomputes the
    highlight index.
    - `setIframeRef(iframe)`: called by the panel component (in
    `migration-tool-jupyter-panel`) once its iframe is in the DOM.
    - `onWorkflowComponentClick(cellUUID)`: public entry called by the
    workflow editor's click handler. If the iframe is present and the cell
    UUID maps to operators, it posts a `triggerCellClick` message to the
    notebook so the matching cell scrolls into view and is highlighted. The
    editor hook fires this once per click and only for operators.
    - `precomputeHighlightMapping` (private): builds a `cellUUID` keyed
    index of `{ components, edges }` from the stored mapping and the current
    graph's links, for O(1) lookup per cell click. It resets on every run so
    entries from a previously opened workflow do not linger, and it records
    each cell's components even when the graph has no links.
    - `handleNotebookMessage` (private): the `window` message handler
    installed in the constructor. It validates `event.origin` against the
    cached Jupyter origin, ignores messages whose data is not an object,
    then dispatches `highlightFromCell` for `cellClicked` events.
    - `resolveJupyterOrigin` (private): resolves the Jupyter server origin
    once and caches it (the backend serves a process static base URL). Both
    the origin check and the `postMessage` target use it, normalized with
    `new URL(...).origin`, so there is no network call per message or per
    click.
    - `highlightFromCell` (private): issues the `unhighlightOperators` /
    `unhighlightLinks` and `highlightOperators` / `highlightLinks` calls
    against `WorkflowActionService`.
    - `fetchNotebookAndMapping` (private): `POST
    /api/notebook-migration/fetch-notebook-and-mapping`, sets the mapping
    cache and forwards the notebook to JupyterLab. Returns `1` if both
    succeeded, `0` otherwise (caught errors included).
    
    The service is a `providedIn: "root"` singleton with no unused
    dependencies, decorators, or dead state.
    
    **`frontend/LICENSE-binary`**
    This service is the first reachable consumer of
    `NotebookMigrationService`, which statically bundles the `ai` SDK, so
    the AI SDK npm packages now land in the frontend bundle.
    `LICENSE-binary` claims them: `ai`, `@ai-sdk/openai`, `@ai-sdk/gateway`,
    `@ai-sdk/provider`, `@ai-sdk/provider-utils`, `@opentelemetry/api`,
    `@vercel/oidc` (Apache-2.0), and `zod`, `eventsource-parser` (MIT). All
    are ASF Category A and none ship a NOTICE file.
    
    ### Any related issues, documentation, discussions?
    Closes #5053
    Parent issue #4301
    
    ### How was this PR tested?
    `jupyter-panel.service.spec.ts` (Vitest) covers `init` (subscribe, stale
    mapping cleanup, fetch), `fetchNotebookAndMapping`,
    `precomputeHighlightMapping` for both linked and linkless graphs plus
    its per run reset, `highlightFromCell`, `onWorkflowComponentClick`
    including the cached origin resolving only once across clicks, and the
    feature flag gate. All tests pass.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    Generated-by: Claude Code (Claude Opus 4.8)
---
 frontend/LICENSE-binary                            |   9 +
 frontend/src/app/app.module.ts                     |   9 +-
 .../workflow-editor/workflow-editor.component.ts   |   7 +-
 .../jupyter-panel/jupyter-panel.service.spec.ts    | 315 +++++++++++++++++++++
 .../service/jupyter-panel/jupyter-panel.service.ts | 282 ++++++++++++++++++
 5 files changed, 620 insertions(+), 2 deletions(-)

diff --git a/frontend/LICENSE-binary b/frontend/LICENSE-binary
index ac390c179e..ec9ea36ede 100644
--- a/frontend/LICENSE-binary
+++ b/frontend/LICENSE-binary
@@ -211,6 +211,13 @@ Dependencies under the Apache License, Version 2.0
 
--------------------------------------------------------------------------------
 
 Angular / npm packages:
+  - @ai-sdk/[email protected]
+  - @ai-sdk/[email protected]
+  - @ai-sdk/[email protected]
+  - @ai-sdk/[email protected]
+  - @opentelemetry/[email protected]
+  - @vercel/[email protected]
+  - [email protected]
   - [email protected]
   - [email protected]
   - [email protected]
@@ -291,6 +298,7 @@ Angular / npm packages:
   - [email protected]
   - [email protected]
   - [email protected]
+  - [email protected]
   - [email protected]
   - [email protected]
   - [email protected]
@@ -337,6 +345,7 @@ Angular / npm packages:
   - [email protected]
   - [email protected]
   - [email protected]
+  - [email protected]
   - [email protected]
 
 
--------------------------------------------------------------------------------
diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 3180ae187f..fef2fd5aa9 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, ErrorHandler, NgModule } 
from "@angular/core";
+import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, APP_BOOTSTRAP_LISTENER, 
ErrorHandler, NgModule } from "@angular/core";
 import { FormsModule, ReactiveFormsModule } from "@angular/forms";
 import { BrowserModule } from "@angular/platform-browser";
 import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
@@ -197,6 +197,7 @@ import { RegistrationRequestModalComponent } from 
"./common/service/user/registr
 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 { UserVenvComponent } from 
"./dashboard/component/user/user-venv/user-venv.component";
+import { JupyterPanelService } from 
"./workspace/service/jupyter-panel/jupyter-panel.service";
 
 registerLocaleData(en);
 
@@ -420,6 +421,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/workflow-editor.component.ts
 
b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
index 042c4669d2..f84ed89e88 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();
   }
@@ -679,6 +681,9 @@ export class WorkflowEditorComponent implements OnInit, 
AfterViewInit, OnDestroy
         const elementID = event[0].model.id.toString();
         const highlightedOperatorIDs = 
this.wrapper.getCurrentHighlightedOperatorIDs();
         const highlightedCommentBoxIDs = 
this.wrapper.getCurrentHighlightedCommentBoxIDs();
+        if 
(this.workflowActionService.getTexeraGraph().hasOperator(elementID)) {
+          this.jupyterPanelService.onWorkflowComponentClick(elementID); // 
highlight corresponding Jupyter notebook cell
+        }
         if (event[1].shiftKey) {
           // if in multiselect toggle highlights on click
           if (highlightedOperatorIDs.includes(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 0000000000..a90714c041
--- /dev/null
+++ 
b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.spec.ts
@@ -0,0 +1,315 @@
+/**
+ * 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 { NotebookMigrationService } from 
"../notebook-migration/notebook-migration.service";
+import { GuiConfigService } from "src/app/common/service/gui-config.service";
+import { firstValueFrom, of } from "rxjs";
+
+describe("JupyterPanelService", () => {
+  let service: JupyterPanelService;
+  let httpMock: HttpTestingController;
+
+  let mockWorkflow: 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: vi.fn().mockReturnValue(of({ wid: 1 })),
+      getWorkflow: vi.fn().mockReturnValue({ wid: 1 }),
+      getTexeraGraph: vi.fn().mockReturnValue({
+        getAllLinks: () => [
+          {
+            linkID: "L1",
+            source: { operatorID: "A" },
+            target: { operatorID: "B" },
+          },
+        ],
+        getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }],
+      }),
+      highlightOperators: vi.fn(),
+      highlightLinks: vi.fn(),
+      unhighlightOperators: vi.fn(),
+      unhighlightLinks: vi.fn(),
+    };
+
+    mockNotebook = {
+      hasMapping: vi.fn().mockReturnValue(true),
+      getMapping: vi.fn().mockReturnValue({
+        cell_to_operator: {
+          cell1: ["A", "B"],
+        },
+        operator_to_cell: {},
+      }),
+      deleteMapping: vi.fn(),
+      setMapping: vi.fn(),
+      getJupyterURL: vi.fn().mockResolvedValue("http://jupyter";),
+    };
+
+    mockGuiConfig = { env: { pythonNotebookMigrationEnabled: true } };
+
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [
+        JupyterPanelService,
+        { provide: WorkflowActionService, useValue: mockWorkflow },
+        { provide: NotebookMigrationService, useValue: mockNotebook },
+        { provide: GuiConfigService, useValue: mockGuiConfig },
+      ],
+    });
+
+    service = TestBed.inject(JupyterPanelService);
+    httpMock = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpMock.verify();
+  });
+
+  // HTTP fetchNotebookAndMapping
+  it("should return 0 when exists=false", async () => {
+    const resultPromise = firstValueFrom((service as 
any).fetchNotebookAndMapping(1, 1));
+
+    const req = httpMock.expectOne(r => 
r.url.includes("/notebook-migration/fetch-notebook-and-mapping"));
+    req.flush({ exists: false });
+
+    expect(await resultPromise).toBe(0);
+  });
+
+  // init(): subscribes to workflow changes, drops the stale mapping for the
+  // current workflow, and fetches the incoming workflow's notebook + mapping.
+  it("init subscribes, drops the stale mapping, and fetches for the new 
workflow", () => {
+    service.init();
+
+    expect(mockWorkflow.workflowMetaDataChanged).toHaveBeenCalled();
+    expect(mockNotebook.deleteMapping).toHaveBeenCalledWith("mapping_wid_1");
+
+    const req = httpMock.expectOne(r => 
r.url.includes("/notebook-migration/fetch-notebook-and-mapping"));
+    req.flush({ exists: false });
+  });
+
+  // Switching workflows must clear the highlight index even when the incoming
+  // workflow has no stored notebook (fetch returns exists=false), otherwise 
the
+  // previous workflow's highlights stay active.
+  it("init clears the highlight index on every workflow change", () => {
+    (service as any).cellToHighlightMapping = { stale: { components: ["X"], 
edges: [] } };
+
+    service.init();
+
+    // Cleared synchronously in the subscription, before the fetch resolves.
+    expect((service as any).cellToHighlightMapping).toEqual({});
+
+    httpMock.expectOne(r => 
r.url.includes("/notebook-migration/fetch-notebook-and-mapping")).flush({ 
exists: false });
+  });
+
+  // An unsaved workflow has an undefined wid; init must not POST for it.
+  it("init does not fetch for an unsaved workflow (undefined wid)", () => {
+    mockWorkflow.workflowMetaDataChanged.mockReturnValue(of({ wid: undefined 
}));
+    mockWorkflow.getWorkflow.mockReturnValue({ wid: undefined });
+
+    service.init();
+
+    httpMock.expectNone(r => 
r.url.includes("/notebook-migration/fetch-notebook-and-mapping"));
+  });
+
+  // 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");
+  });
+
+  // handleNotebookMessage must only act on cellClicked messages that come from
+  // our own iframe (event.source) AND carry the Jupyter origin.
+  it("handleNotebookMessage highlights only for messages from the iframe at 
the Jupyter origin", async () => {
+    const iframeWindow = {} as Window;
+    service.setIframeRef({ contentWindow: iframeWindow } as any);
+    const highlightSpy = vi.spyOn(service as any, 
"highlightFromCell").mockImplementation(() => {});
+    const handle = (service as any).handleNotebookMessage;
+
+    // wrong source (some other frame/script): ignored
+    await handle({ source: {}, origin: "http://jupyter";, data: { action: 
"cellClicked", cellUUID: "c1" } });
+    expect(highlightSpy).not.toHaveBeenCalled();
+
+    // right source, wrong origin: ignored
+    await handle({ source: iframeWindow, origin: "http://evil";, data: { 
action: "cellClicked", cellUUID: "c1" } });
+    expect(highlightSpy).not.toHaveBeenCalled();
+
+    // right source and origin: highlights
+    await handle({ source: iframeWindow, origin: "http://jupyter";, data: { 
action: "cellClicked", cellUUID: "c1" } });
+    expect(highlightSpy).toHaveBeenCalledWith("c1");
+  });
+
+  // A workflow with operators but no links is valid; precompute must still
+  // record each cell's components (with empty edges) so cell clicks highlight.
+  it("precomputes component mappings even when the graph has no links", () => {
+    mockWorkflow.getTexeraGraph.mockReturnValue({
+      getAllLinks: () => [],
+      getAllOperators: () => [{ operatorID: "A" }, { operatorID: "B" }],
+    });
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: { cell1: ["A", "B"] },
+      operator_to_cell: {},
+    });
+
+    (service as any).precomputeHighlightMapping();
+
+    expect((service as any).cellToHighlightMapping).toEqual({
+      cell1: { components: ["A", "B"], edges: [] },
+    });
+  });
+
+  // Switching workflows re-runs precompute; the map must reflect only the
+  // current workflow, not accumulate entries from previously opened ones.
+  it("resets the highlight mapping on each precompute", () => {
+    mockWorkflow.getTexeraGraph.mockReturnValue({
+      getAllLinks: () => [],
+      getAllOperators: () => [],
+    });
+
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: { cellA: ["A"] },
+      operator_to_cell: {},
+    });
+    (service as any).precomputeHighlightMapping();
+
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: { cellB: ["B"] },
+      operator_to_cell: {},
+    });
+    (service as any).precomputeHighlightMapping();
+
+    expect((service as any).cellToHighlightMapping).toEqual({
+      cellB: { components: ["B"], edges: [] },
+    });
+  });
+
+  // onWorkflowComponentClick
+  it("should postMessage when mapping exists", async () => {
+    const mockIframe = {
+      contentWindow: {
+        postMessage: vi.fn(),
+      },
+    } as any;
+
+    service.setIframeRef(mockIframe);
+    mockNotebook.getMapping.mockReturnValue({
+      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";
+    );
+  });
+
+  it("does not postMessage when the operator maps to no cells", async () => {
+    const mockIframe = {
+      contentWindow: { postMessage: vi.fn() },
+    } as any;
+    service.setIframeRef(mockIframe);
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: {},
+      operator_to_cell: { op1: [] },
+    });
+
+    await service.onWorkflowComponentClick("op1");
+
+    expect(mockIframe.contentWindow.postMessage).not.toHaveBeenCalled();
+  });
+
+  // The Jupyter origin is process-static, so it must be resolved once and 
cached
+  // rather than re-fetched on every click / incoming message.
+  it("resolves the Jupyter URL only once across multiple clicks", async () => {
+    const mockIframe = {
+      contentWindow: { postMessage: vi.fn() },
+    } as any;
+    service.setIframeRef(mockIframe);
+    mockNotebook.getMapping.mockReturnValue({
+      cell_to_operator: {},
+      operator_to_cell: { cell1: ["op1"] },
+    });
+
+    await service.onWorkflowComponentClick("cell1");
+    await service.onWorkflowComponentClick("cell1");
+    await service.onWorkflowComponentClick("cell1");
+
+    expect(mockNotebook.getJupyterURL).toHaveBeenCalledTimes(1);
+  });
+
+  // Feature flag gate (defence in depth). With the flag off, init must not
+  // subscribe to workflow changes, and onWorkflowComponentClick must not
+  // postMessage to the iframe. 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("onWorkflowComponentClick does not postMessage to the iframe", async () 
=> {
+      const mockIframe = {
+        contentWindow: { postMessage: vi.fn() },
+      } 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 0000000000..f2083e5c5d
--- /dev/null
+++ b/frontend/src/app/workspace/service/jupyter-panel/jupyter-panel.service.ts
@@ -0,0 +1,282 @@
+/**
+ * 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 { 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 { 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";
+
+@Injectable({
+  providedIn: "root",
+})
+export class JupyterPanelService {
+  private iframeRef: HTMLIFrameElement | null = null; // Store reference to 
iframe element
+
+  // Precomputed dictionary for cell to highlight mapping
+  private cellToHighlightMapping: Record<string, { components: string[]; 
edges: string[] }> = {};
+
+  // Cached Jupyter server origin (see resolveJupyterOrigin)
+  private jupyterOrigin: Promise<string | null> | null = null;
+
+  constructor(
+    private workflowActionService: WorkflowActionService,
+    private http: HttpClient,
+    private notebookMigrationService: NotebookMigrationService,
+    private config: GuiConfigService
+  ) {
+    window.addEventListener("message", this.handleNotebookMessage);
+  }
+
+  private get enabled(): boolean {
+    return this.config.env.pythonNotebookMigrationEnabled;
+  }
+
+  /**
+   * Resolve and cache the Jupyter server origin, used both to validate 
incoming
+   * iframe messages and as the postMessage target. The backend serves a
+   * process-static base URL, so the origin is fixed for the app's lifetime.
+   * A failed/unavailable lookup is not cached, so it can be retried once the
+   * Jupyter pod becomes reachable.
+   */
+  private resolveJupyterOrigin(): Promise<string | null> {
+    if (this.jupyterOrigin) {
+      return this.jupyterOrigin;
+    }
+    const pending: Promise<string | null> = 
this.notebookMigrationService.getJupyterURL().then(url => {
+      if (url) {
+        try {
+          return new URL(url).origin;
+        } catch {
+          /* malformed URL — fall through to retry */
+        }
+      }
+      this.jupyterOrigin = null; // don't cache failures
+      return null;
+    });
+    this.jupyterOrigin = pending;
+    return pending;
+  }
+
+  public init(): void {
+    if (!this.enabled) return;
+    this.workflowActionService
+      .workflowMetaDataChanged()
+      .pipe(
+        map(meta => meta.wid),
+        distinctUntilChanged()
+      )
+      .subscribe(wid => {
+        // On every workflow change, drop the outgoing workflow's stale mapping
+        // and clear the highlight index. Clearing here (not only inside
+        // precomputeHighlightMapping, which runs only on a successful fetch)
+        // ensures switching to a workflow without a stored notebook can't 
leave
+        // the previous workflow's highlights active. This cleanup previously
+        // happened inside closeJupyterNotebookPanel; the panel-visibility
+        // surface lives with the iframe component in
+        // `migration-tool-jupyter-panel` now, so it is inlined.
+        const currentWid = this.workflowActionService.getWorkflow().wid;
+        if (currentWid !== undefined) {
+          this.notebookMigrationService.deleteMapping("mapping_wid_" + 
currentWid);
+        }
+        this.cellToHighlightMapping = {};
+        // Skip unsaved workflows (wid undefined) and wid 0; both would POST
+        // without a usable wid and 500 on the backend.
+        if (wid) {
+          this.fetchNotebookAndMapping(wid).subscribe(result => {
+            if (result == 1) {
+              this.precomputeHighlightMapping();
+              // Panel auto-open on workflow restore is wired in
+              // `migration-tool-jupyter-panel` once the visibility API exists.
+            }
+          });
+        }
+      });
+  }
+
+  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 {
+    // Rebuild from scratch so entries from a previously opened workflow don't 
linger.
+    this.cellToHighlightMapping = {};
+
+    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();
+
+    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). The panel
+  // component that calls this lives in `migration-tool-jupyter-panel`.
+  setIframeRef(iframe: HTMLIFrameElement) {
+    this.iframeRef = iframe;
+  }
+
+  // Handle messages from the Jupyter notebook iframe
+  private handleNotebookMessage = async (event: MessageEvent) => {
+    if (!this.enabled) return;
+
+    // Only accept messages posted by our own notebook iframe. This is the
+    // strong check: it rejects any other same-origin frame or script trying to
+    // drive highlighting with a synthetic cellClicked message.
+    if (!this.iframeRef || event.source !== this.iframeRef.contentWindow) {
+      return;
+    }
+
+    // Defense in depth: also require the message origin to match the resolved
+    // Jupyter origin.
+    const jupyterOrigin = await this.resolveJupyterOrigin();
+    if (!jupyterOrigin || event.origin !== jupyterOrigin) {
+      return;
+    }
+
+    const { action, cellUUID } = event.data ?? {};
+    if (action === "cellClicked") {
+      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 operator is clicked to trigger the corresponding 
notebook cell(s)
+  async onWorkflowComponentClick(operatorId: string): Promise<void> {
+    if (!this.enabled) return;
+    const jupyterOrigin = await this.resolveJupyterOrigin();
+    if (jupyterOrigin && 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 cellIds = mappingEntry["operator_to_cell"][operatorId];
+      if (cellIds && cellIds.length > 0) {
+        // "operators" is the payload key custom.js expects; the values are the
+        // mapped cell UUIDs for the clicked operator.
+        this.iframeRef.contentWindow.postMessage({ action: "triggerCellClick", 
operators: cellIds }, jupyterOrigin);
+      } else {
+        console.error(`No cells mapped to operator: ${operatorId}`);
+      }
+    }
+  }
+}

Reply via email to