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

Yicong-Huang 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 20ef8254f2 refactor(ui): encapsulate OperatorMenuService state and add 
subscription cleanup (#4530)
20ef8254f2 is described below

commit 20ef8254f209f36d1fb8ee519756c046a53b5475
Author: Yicong Huang <[email protected]>
AuthorDate: Thu Apr 30 22:48:29 2026 -0700

    refactor(ui): encapsulate OperatorMenuService state and add subscription 
cleanup (#4530)
    
    ### What changes were proposed in this PR?
    
    Refactor
    `frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts`
    to address two coupled code-quality issues:
    
    1. **Encapsulate state.** `highlightedOperators` and
    `highlightedCommentBoxes` are now private `BehaviorSubject`s, exposed
    only as readonly `Observable`s (`highlightedOperators$`,
    `highlightedCommentBoxes$`). External callers can no longer call
    `.next()` and corrupt service state.
    2. **Add subscription cleanup.** All five constructor subscriptions now
    use `untilDestroyed(this)` (consistent with the rest of the codebase).
    HMR no longer leaves ghost subscriptions on the old `JointGraphWrapper`
    / `TexeraGraph` instances.
    3. **Eliminate fan-out.** Previously, three downstream handlers
    (`handleDisableOperatorStatusChange`,
    `handleViewResultOperatorStatusChange`,
    `handleReuseOperatorResultStatusChange`) all subscribed to the public
    `highlightedOperators` BehaviorSubject. A single user highlight action
    triggered the upstream handler that called `.next()`, which then
    re-fired all three downstream handlers — a 4× cascade per highlight
    event. The refactored code uses one private `recomputeMenuState()`
    method invoked directly from the upstream handler, plus a single
    `merge(...)` over the texera-graph state-change streams. One highlight =
    one recompute.
    
    ### Consumer migration
    
    - `ContextMenuComponent`: subscribes to the two Observables in its
    constructor (with `untilDestroyed`) and stores `highlightedOperatorIds`
    / `highlightedCommentBoxIds` as local fields for the template.
    - `context-menu.component.html`: template `*ngIf` checks now reference
    the component's local fields instead of
    `operatorMenuService.highlightedOperators.value`.
    - `WorkflowEditorComponent` copy/cut handlers: use
    `withLatestFrom(operatorMenu.highlightedOperators$,
    operatorMenu.highlightedCommentBoxes$)` instead of reading `.value`.
    
    ### Any related issues, documentation, discussions?
    
    Closes #4529
    
    ### How was this PR tested?
    
    - Replaced the placeholder `OperatorMenuService` spec with 9 new tests
    covering: empty initial state, encapsulation (BehaviorSubjects no longer
    publicly accessible), highlight/unhighlight propagation to both
    observables, the no-fan-out invariant (a single highlight produces
    exactly one emission on `highlightedOperators$`), sink exclusion in
    view-result/reuse-result targets, and recomputation triggered by
    `getViewResultOperatorsChangedStream` and the
    workflow-modification-enabled stream.
    - Existing `ContextMenuComponent`, `WorkflowEditorComponent`,
    `PropertyEditorComponent`, `ResultPanelComponent`, and
    `JointGraphWrapper` specs continue to pass.
    - `yarn ng build` is clean (no new TS errors).
    - `yarn format:fix` reports no changes.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (claude-opus-4-7)
---
 .../context-menu/context-menu.component.html       |  24 +--
 .../context-menu/context-menu.component.spec.ts    |   6 +-
 .../context-menu/context-menu.component.ts         |   8 +
 .../workflow-editor/workflow-editor.component.ts   |  18 +--
 .../operator-menu/operator-menu.service.spec.ts    | 139 ++++++++++++++++++
 .../service/operator-menu/operator-menu.service.ts | 162 +++++++++------------
 6 files changed, 238 insertions(+), 119 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
index 7a6fcba6f2..4465d65cb2 100644
--- 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
+++ 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
@@ -20,8 +20,8 @@
 <ul nz-menu>
   <li
     nz-menu-item
-    *ngIf="(operatorMenuService.highlightedOperators.value.length > 0 ||
-  operatorMenuService.highlightedCommentBoxes.value.length > 0) &&
+    *ngIf="(highlightedOperatorIds.length > 0 ||
+  highlightedCommentBoxIds.length > 0) &&
   !hasHighlightedLinks()"
     (click)="onCopy()">
     <span
@@ -32,8 +32,8 @@
   </li>
   <li
     nz-menu-item
-    *ngIf="(operatorMenuService.highlightedOperators.value.length > 0 ||
-  operatorMenuService.highlightedCommentBoxes.value.length > 0) &&
+    *ngIf="(highlightedOperatorIds.length > 0 ||
+  highlightedCommentBoxIds.length > 0) &&
   !hasHighlightedLinks() &&
   isWorkflowModifiable"
     (click)="onCut()">
@@ -45,8 +45,8 @@
   </li>
   <li
     nz-menu-item
-    *ngIf="(operatorMenuService.highlightedOperators.value.length === 0 &&
-  operatorMenuService.highlightedCommentBoxes.value.length === 0 &&
+    *ngIf="(highlightedOperatorIds.length === 0 &&
+  highlightedCommentBoxIds.length === 0 &&
   !hasHighlightedLinks()) &&
   isWorkflowModifiable"
     (click)="onPaste()">
@@ -125,8 +125,8 @@
   </li>
   <li
     nz-menu-item
-    *ngIf="(operatorMenuService.highlightedOperators.value.length > 0 ||
-  operatorMenuService.highlightedCommentBoxes.value.length > 0) &&
+    *ngIf="(highlightedOperatorIds.length > 0 ||
+  highlightedCommentBoxIds.length > 0) &&
   isWorkflowModifiable"
     (click)="onDelete()">
     <span
@@ -140,8 +140,8 @@
   <li
     nz-menu-item
     *ngIf="hasHighlightedLinks() && 
-  operatorMenuService.highlightedOperators.value.length === 0 &&
-  operatorMenuService.highlightedCommentBoxes.value.length === 0 &&
+  highlightedOperatorIds.length === 0 &&
+  highlightedCommentBoxIds.length === 0 &&
   isWorkflowModifiable"
     (click)="onDelete()">
     <span
@@ -153,8 +153,8 @@
 
   <li
     nz-menu-item
-    *ngIf="operatorMenuService.highlightedOperators.value.length === 1 &&
-  operatorMenuService.highlightedCommentBoxes.value.length === 0 &&
+    *ngIf="highlightedOperatorIds.length === 1 &&
+  highlightedCommentBoxIds.length === 0 &&
   !hasHighlightedLinks()"
     [nzDisabled]="!canExecuteOperator()"
     (click)="operatorMenuService.executeUpToOperator()">
diff --git 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
index 115240c4e0..f7e31f40b3 100644
--- 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.spec.ts
@@ -27,7 +27,7 @@ import { WorkflowActionService } from 
"src/app/workspace/service/workflow-graph/
 import { WorkflowResultService } from 
"src/app/workspace/service/workflow-result/workflow-result.service";
 import { WorkflowResultExportService } from 
"src/app/workspace/service/workflow-result-export/workflow-result-export.service";
 import { OperatorMenuService } from 
"src/app/workspace/service/operator-menu/operator-menu.service";
-import { BehaviorSubject, of } from "rxjs";
+import { of } from "rxjs";
 import { ReactiveFormsModule } from "@angular/forms";
 import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
 import { NzDropDownModule } from "ng-zorro-antd/dropdown";
@@ -94,8 +94,8 @@ describe("ContextMenuComponent", () => {
 
     // Create a mock for OperatorMenuService with necessary properties and 
methods
     operatorMenuService = {
-      highlightedOperators: new BehaviorSubject<any[]>([]),
-      highlightedCommentBoxes: new BehaviorSubject<any[]>([]),
+      highlightedOperators$: of([] as readonly string[]),
+      highlightedCommentBoxes$: of([] as readonly string[]),
       isDisableOperator: false,
       isDisableOperatorClickable: false,
       isToViewResult: false,
diff --git 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
index 15d3917a06..2dd0b67dee 100644
--- 
a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
+++ 
b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
@@ -37,6 +37,8 @@ import { GuiConfigService } from 
"../../../../../common/service/gui-config.servi
 })
 export class ContextMenuComponent {
   public isWorkflowModifiable: boolean = false;
+  public highlightedOperatorIds: readonly string[] = [];
+  public highlightedCommentBoxIds: readonly string[] = [];
 
   constructor(
     public workflowActionService: WorkflowActionService,
@@ -48,6 +50,12 @@ export class ContextMenuComponent {
     private validationWorkflowService: ValidationWorkflowService
   ) {
     this.registerWorkflowModifiableChangedHandler();
+    this.operatorMenuService.highlightedOperators$
+      .pipe(untilDestroyed(this))
+      .subscribe(ids => (this.highlightedOperatorIds = ids));
+    this.operatorMenuService.highlightedCommentBoxes$
+      .pipe(untilDestroyed(this))
+      .subscribe(ids => (this.highlightedCommentBoxIds = ids));
   }
 
   public canExecuteOperator(): boolean {
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 6a0fcf4b1d..fc6deeab85 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
@@ -30,7 +30,7 @@ import { WorkflowActionService } from 
"../../service/workflow-graph/model/workfl
 import { WorkflowStatusService } from 
"../../service/workflow-status/workflow-status.service";
 import { ExecutionState, OperatorState } from 
"../../types/execute-workflow.interface";
 import { LogicalPort, OperatorLink, OperatorPredicate } from 
"../../types/workflow-common.interface";
-import { auditTime, filter, map, takeUntil } from "rxjs/operators";
+import { auditTime, filter, map, takeUntil, withLatestFrom } from 
"rxjs/operators";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { UndoRedoService } from "../../service/undo-redo/undo-redo.service";
 import { WorkflowVersionService } from 
"../../../dashboard/service/user/workflow-version/workflow-version.service";
@@ -1126,13 +1126,11 @@ export class WorkflowEditorComponent implements OnInit, 
AfterViewInit, OnDestroy
     fromEvent<ClipboardEvent>(document, "copy")
       .pipe(
         filter(_ => document.activeElement === document.body),
+        withLatestFrom(this.operatorMenu.highlightedOperators$, 
this.operatorMenu.highlightedCommentBoxes$),
         untilDestroyed(this)
       )
-      .subscribe(() => {
-        if (
-          this.operatorMenu.highlightedOperators.value.length > 0 ||
-          this.operatorMenu.highlightedCommentBoxes.value.length > 0
-        ) {
+      .subscribe(([_, highlightedOperators, highlightedCommentBoxes]) => {
+        if (highlightedOperators.length > 0 || highlightedCommentBoxes.length 
> 0) {
           this.operatorMenu.saveHighlightedElements();
         }
       });
@@ -1148,13 +1146,11 @@ export class WorkflowEditorComponent implements OnInit, 
AfterViewInit, OnDestroy
       .pipe(
         filter(() => document.activeElement === document.body),
         filter(() => this.interactive),
+        withLatestFrom(this.operatorMenu.highlightedOperators$, 
this.operatorMenu.highlightedCommentBoxes$),
         untilDestroyed(this)
       )
-      .subscribe(() => {
-        if (
-          this.operatorMenu.highlightedOperators.value.length > 0 ||
-          this.operatorMenu.highlightedCommentBoxes.value.length > 0
-        ) {
+      .subscribe(([_, highlightedOperators, highlightedCommentBoxes]) => {
+        if (highlightedOperators.length > 0 || highlightedCommentBoxes.length 
> 0) {
           this.operatorMenu.saveHighlightedElements();
           this.deleteElements();
         }
diff --git 
a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts
 
b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts
index 8b4f3c9b5a..2e20f52ac0 100644
--- 
a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts
+++ 
b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts
@@ -26,9 +26,22 @@ import { HttpClientModule } from "@angular/common/http";
 import { ComputingUnitStatusService } from 
"../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
 import { MockComputingUnitStatusService } from 
"../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
 import { commonTestProviders } from "../../../common/testing/test-utils";
+import { WorkflowActionService } from 
"../workflow-graph/model/workflow-action.service";
+import {
+  mockCommentBox,
+  mockPoint,
+  mockResultPredicate,
+  mockScanPredicate,
+  mockSentimentPredicate,
+} from "../workflow-graph/model/mock-workflow-data";
+import { Subscription } from "rxjs";
 
 describe("OperatorMenuService", () => {
   let service: OperatorMenuService;
+  let workflowActionService: WorkflowActionService;
+  let opsLatest: readonly string[] = [];
+  let boxesLatest: readonly string[] = [];
+  let subs: Subscription;
 
   beforeEach(() => {
     TestBed.configureTestingModule({
@@ -39,10 +52,136 @@ describe("OperatorMenuService", () => {
       ],
       imports: [HttpClientModule],
     });
+    workflowActionService = TestBed.inject(WorkflowActionService);
     service = TestBed.inject(OperatorMenuService);
+
+    subs = new Subscription();
+    subs.add(service.highlightedOperators$.subscribe(ids => (opsLatest = 
ids)));
+    subs.add(service.highlightedCommentBoxes$.subscribe(ids => (boxesLatest = 
ids)));
   });
 
+  afterEach(() => subs.unsubscribe());
+
   it("should be created", () => {
     expect(service).toBeTruthy();
   });
+
+  it("starts with empty highlighted snapshots", () => {
+    expect(opsLatest).toEqual([]);
+    expect(boxesLatest).toEqual([]);
+  });
+
+  it("does not expose mutable BehaviorSubjects on the public API", () => {
+    // service must not let outside code call .next() on its internal state.
+    expect((service as any).highlightedOperators).toBeUndefined();
+    expect((service as any).highlightedCommentBoxes).toBeUndefined();
+  });
+
+  it("emits the new highlighted operator IDs on highlightedOperators$", () => {
+    workflowActionService.addOperator(mockScanPredicate, mockPoint);
+    
workflowActionService.getJointGraphWrapper().highlightOperators(mockScanPredicate.operatorID);
+
+    expect(opsLatest).toEqual([mockScanPredicate.operatorID]);
+  });
+
+  it("emits the new highlighted comment box IDs on highlightedCommentBoxes$", 
() => {
+    workflowActionService.addCommentBox(mockCommentBox);
+    
workflowActionService.getJointGraphWrapper().highlightCommentBoxes(mockCommentBox.commentBoxID);
+
+    expect(boxesLatest).toEqual([mockCommentBox.commentBoxID]);
+  });
+
+  it("emits exactly once on highlightedOperators$ per highlight change (no 
fan-out)", () => {
+    const emissions: string[][] = [];
+    const sub = service.highlightedOperators$.subscribe(ids => 
emissions.push([...ids]));
+    // BehaviorSubject seed
+    expect(emissions.length).toBe(1);
+
+    workflowActionService.addOperator(mockScanPredicate, mockPoint);
+    
workflowActionService.getJointGraphWrapper().highlightOperators(mockScanPredicate.operatorID);
+
+    // a single highlight event must produce a single emission, not 4 (one per 
dependent handler).
+    expect(emissions.length).toBe(2);
+    expect(emissions[1]).toEqual([mockScanPredicate.operatorID]);
+
+    
workflowActionService.getJointGraphWrapper().unhighlightOperators(mockScanPredicate.operatorID);
+    expect(emissions.length).toBe(3);
+    expect(emissions[2]).toEqual([]);
+
+    sub.unsubscribe();
+  });
+
+  describe("button state recomputation", () => {
+    it("makes disable button clickable when an operator is highlighted and 
modification is enabled", () => {
+      workflowActionService.addOperator(mockScanPredicate, mockPoint);
+      
workflowActionService.getJointGraphWrapper().highlightOperators(mockScanPredicate.operatorID);
+
+      expect(service.isDisableOperatorClickable).toBeTrue();
+      expect(service.isDisableOperator).toBeTrue();
+    });
+
+    it("flips isDisableOperator to enable after the operator is disabled", () 
=> {
+      workflowActionService.addOperator(mockScanPredicate, mockPoint);
+      
workflowActionService.getJointGraphWrapper().highlightOperators(mockScanPredicate.operatorID);
+      workflowActionService.disableOperators([mockScanPredicate.operatorID]);
+
+      // all highlighted operators are now disabled, so clicking should 
re-enable them.
+      expect(service.isDisableOperator).toBeFalse();
+    });
+
+    it("excludes sinks from view-result targets", () => {
+      workflowActionService.addOperatorsAndLinks(
+        [
+          { op: mockScanPredicate, pos: mockPoint },
+          { op: mockResultPredicate, pos: mockPoint },
+        ],
+        []
+      );
+      const wrapper = workflowActionService.getJointGraphWrapper();
+      // start from a clean highlight state — addOperator may auto-highlight 
new operators.
+      
wrapper.unhighlightOperators(...wrapper.getCurrentHighlightedOperatorIDs());
+
+      // highlighting only a sink: view-result should not be clickable.
+      wrapper.highlightOperators(mockResultPredicate.operatorID);
+      expect(service.isToViewResultClickable).toBeFalse();
+      expect(service.isReuseResultClickable).toBeFalse();
+
+      // highlighting only a non-sink: view-result becomes clickable.
+      wrapper.unhighlightOperators(mockResultPredicate.operatorID);
+      wrapper.highlightOperators(mockScanPredicate.operatorID);
+      expect(service.isToViewResultClickable).toBeTrue();
+      expect(service.isReuseResultClickable).toBeTrue();
+    });
+
+    it("recomputes when modification-enabled stream fires without a highlight 
change", () => {
+      workflowActionService.addOperator(mockScanPredicate, mockPoint);
+      
workflowActionService.getJointGraphWrapper().highlightOperators(mockScanPredicate.operatorID);
+      expect(service.isDisableOperatorClickable).toBeTrue();
+
+      workflowActionService.disableWorkflowModification();
+      expect(service.isDisableOperatorClickable).toBeFalse();
+
+      workflowActionService.enableWorkflowModification();
+      expect(service.isDisableOperatorClickable).toBeTrue();
+    });
+
+    it("recomputes when view-result state of a highlighted non-sink operator 
changes", () => {
+      workflowActionService.addOperatorsAndLinks(
+        [
+          { op: mockScanPredicate, pos: mockPoint },
+          { op: mockSentimentPredicate, pos: mockPoint },
+        ],
+        []
+      );
+      workflowActionService
+        .getJointGraphWrapper()
+        .highlightOperators(mockScanPredicate.operatorID, 
mockSentimentPredicate.operatorID);
+
+      expect(service.isToViewResult).toBeTrue();
+
+      
workflowActionService.setViewOperatorResults([mockScanPredicate.operatorID, 
mockSentimentPredicate.operatorID]);
+      // both highlighted non-sinks are now viewing results → next click 
should toggle off.
+      expect(service.isToViewResult).toBeFalse();
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts 
b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts
index ac39301d24..1b800e9f7b 100644
--- a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts
+++ b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts
@@ -20,11 +20,12 @@
 import { Injectable } from "@angular/core";
 import { WorkflowActionService } from 
"../workflow-graph/model/workflow-action.service";
 import { isSink } from "../workflow-graph/model/workflow-graph";
-import { BehaviorSubject, merge } from "rxjs";
+import { BehaviorSubject, merge, Observable } from "rxjs";
 import { CommentBox, OperatorLink, OperatorPredicate, Point } from 
"../../types/workflow-common.interface";
 import { WorkflowUtilService } from 
"../workflow-graph/util/workflow-util.service";
 import { NotificationService } from 
"src/app/common/service/notification/notification.service";
 import { ExecuteWorkflowService } from 
"../execute-workflow/execute-workflow.service";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 
 type OperatorPositions = {
   [key: string]: Point;
@@ -50,12 +51,17 @@ type SerializedString = {
  *  - right-click menu
  *  - keyboard shortcuts
  */
+@UntilDestroy()
 @Injectable({
   providedIn: "root",
 })
 export class OperatorMenuService {
-  public highlightedOperators = new BehaviorSubject([] as readonly string[]);
-  public highlightedCommentBoxes = new BehaviorSubject([] as readonly 
string[]);
+  private readonly _highlightedOperators$ = new BehaviorSubject<readonly 
string[]>([]);
+  private readonly _highlightedCommentBoxes$ = new BehaviorSubject<readonly 
string[]>([]);
+
+  public readonly highlightedOperators$: Observable<readonly string[]> = 
this._highlightedOperators$.asObservable();
+  public readonly highlightedCommentBoxes$: Observable<readonly string[]> =
+    this._highlightedCommentBoxes$.asObservable();
 
   // whether the disable-operator-button should be enabled
   public isDisableOperatorClickable: boolean = false;
@@ -75,29 +81,38 @@ export class OperatorMenuService {
     private notificationService: NotificationService,
     private executeWorkflowService: ExecuteWorkflowService
   ) {
-    this.handleDisableOperatorStatusChange();
-    this.handleViewResultOperatorStatusChange();
-    this.handleReuseOperatorResultStatusChange();
+    const jointGraphWrapper = 
this.workflowActionService.getJointGraphWrapper();
+    const texeraGraph = this.workflowActionService.getTexeraGraph();
 
     merge(
-      
this.workflowActionService.getJointGraphWrapper().getJointOperatorHighlightStream(),
-      
this.workflowActionService.getJointGraphWrapper().getJointOperatorUnhighlightStream(),
-      
this.workflowActionService.getJointGraphWrapper().getJointGroupHighlightStream(),
-      
this.workflowActionService.getJointGraphWrapper().getJointGroupUnhighlightStream()
-    ).subscribe(() => {
-      this.highlightedOperators.next(
-        
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()
-      );
-    });
+      jointGraphWrapper.getJointOperatorHighlightStream(),
+      jointGraphWrapper.getJointOperatorUnhighlightStream(),
+      jointGraphWrapper.getJointGroupHighlightStream(),
+      jointGraphWrapper.getJointGroupUnhighlightStream()
+    )
+      .pipe(untilDestroyed(this))
+      .subscribe(() => {
+        
this._highlightedOperators$.next(jointGraphWrapper.getCurrentHighlightedOperatorIDs());
+        this.recomputeMenuState();
+      });
 
     merge(
-      
this.workflowActionService.getJointGraphWrapper().getJointCommentBoxHighlightStream(),
-      
this.workflowActionService.getJointGraphWrapper().getJointCommentBoxUnhighlightStream()
-    ).subscribe(() => {
-      this.highlightedCommentBoxes.next(
-        
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedCommentBoxIDs()
-      );
-    });
+      jointGraphWrapper.getJointCommentBoxHighlightStream(),
+      jointGraphWrapper.getJointCommentBoxUnhighlightStream()
+    )
+      .pipe(untilDestroyed(this))
+      .subscribe(() => {
+        
this._highlightedCommentBoxes$.next(jointGraphWrapper.getCurrentHighlightedCommentBoxIDs());
+      });
+
+    merge(
+      texeraGraph.getDisabledOperatorsChangedStream(),
+      texeraGraph.getViewResultOperatorsChangedStream(),
+      texeraGraph.getReuseCacheOperatorsChangedStream(),
+      this.workflowActionService.getWorkflowModificationEnabledStream()
+    )
+      .pipe(untilDestroyed(this))
+      .subscribe(() => this.recomputeMenuState());
   }
 
   /**
@@ -105,98 +120,59 @@ export class OperatorMenuService {
    * this.isDisableOperator indicates whether the operators should be disabled 
or enabled
    */
   public disableHighlightedOperators(): void {
+    const highlighted = this._highlightedOperators$.value;
     if (this.isDisableOperator) {
-      
this.workflowActionService.disableOperators(this.highlightedOperators.value);
+      this.workflowActionService.disableOperators(highlighted);
     } else {
-      
this.workflowActionService.enableOperators(this.highlightedOperators.value);
+      this.workflowActionService.enableOperators(highlighted);
     }
   }
 
   public viewResultHighlightedOperators(): void {
-    const effectiveHighlightedOperatorsExcludeSink = 
this.highlightedOperators.value.filter(
-      op => 
!isSink(this.workflowActionService.getTexeraGraph().getOperator(op))
-    );
-
+    const targets = this.highlightedOperatorIdsExcludingSinks();
     if (this.isToViewResult) {
-      
this.workflowActionService.setViewOperatorResults(effectiveHighlightedOperatorsExcludeSink);
+      this.workflowActionService.setViewOperatorResults(targets);
     } else {
-      
this.workflowActionService.unsetViewOperatorResults(effectiveHighlightedOperatorsExcludeSink);
+      this.workflowActionService.unsetViewOperatorResults(targets);
     }
   }
 
   public reuseResultHighlightedOperator(): void {
-    const effectiveHighlightedOperatorsExcludeSink = 
this.highlightedOperators.value.filter(
-      op => 
!isSink(this.workflowActionService.getTexeraGraph().getOperator(op))
-    );
-
+    const targets = this.highlightedOperatorIdsExcludingSinks();
     if (this.isMarkForReuse) {
-      
this.workflowActionService.markReuseResults(effectiveHighlightedOperatorsExcludeSink);
+      this.workflowActionService.markReuseResults(targets);
     } else {
-      
this.workflowActionService.removeMarkReuseResults(effectiveHighlightedOperatorsExcludeSink);
+      this.workflowActionService.removeMarkReuseResults(targets);
     }
   }
 
   /**
-   * Updates the status of the disable operator icon:
-   * If all selected operators are disabled, then click it will re-enable the 
operators
-   * If any of the selected operator is not disabled, then click will disable 
all selected operators
+   * Recomputes the three button states from current state. Called whenever
+   * highlighted operators change or any underlying texera-graph state changes 
—
+   * a single linear update path that replaces the previous fan-out via shared 
BehaviorSubject.
    */
-  handleDisableOperatorStatusChange() {
-    merge(
-      this.highlightedOperators,
-      
this.workflowActionService.getTexeraGraph().getDisabledOperatorsChangedStream(),
-      this.workflowActionService.getWorkflowModificationEnabledStream()
-    ).subscribe(event => {
-      const allDisabled = this.highlightedOperators.value.every(op =>
-        this.workflowActionService.getTexeraGraph().isOperatorDisabled(op)
-      );
-
-      this.isDisableOperator = !allDisabled;
-      this.isDisableOperatorClickable =
-        this.highlightedOperators.value.length !== 0 && 
this.workflowActionService.checkWorkflowModificationEnabled();
-    });
-  }
-
-  handleViewResultOperatorStatusChange() {
-    merge(
-      this.highlightedOperators,
-      
this.workflowActionService.getTexeraGraph().getViewResultOperatorsChangedStream(),
-      this.workflowActionService.getWorkflowModificationEnabledStream()
-    ).subscribe(event => {
-      const effectiveHighlightedOperatorsExcludeSink = 
this.highlightedOperators.value.filter(
-        op => 
!isSink(this.workflowActionService.getTexeraGraph().getOperator(op))
-      );
-
-      const allViewing = effectiveHighlightedOperatorsExcludeSink.every(op =>
-        this.workflowActionService.getTexeraGraph().isViewingResult(op)
-      );
-
-      this.isToViewResult = !allViewing;
-      this.isToViewResultClickable =
-        effectiveHighlightedOperatorsExcludeSink.length !== 0 &&
-        this.workflowActionService.checkWorkflowModificationEnabled();
-    });
+  private recomputeMenuState(): void {
+    const texeraGraph = this.workflowActionService.getTexeraGraph();
+    const modificationEnabled = 
this.workflowActionService.checkWorkflowModificationEnabled();
+    const highlighted = this._highlightedOperators$.value;
+    const highlightedExcludingSinks = 
this.highlightedOperatorIdsExcludingSinks();
+
+    const allDisabled = highlighted.every(op => 
texeraGraph.isOperatorDisabled(op));
+    this.isDisableOperator = !allDisabled;
+    this.isDisableOperatorClickable = highlighted.length !== 0 && 
modificationEnabled;
+
+    const allViewing = highlightedExcludingSinks.every(op => 
texeraGraph.isViewingResult(op));
+    this.isToViewResult = !allViewing;
+    this.isToViewResultClickable = highlightedExcludingSinks.length !== 0 && 
modificationEnabled;
+
+    const allMarkedForReuse = highlightedExcludingSinks.every(op => 
texeraGraph.isMarkedForReuseResult(op));
+    this.isMarkForReuse = !allMarkedForReuse;
+    this.isReuseResultClickable = highlightedExcludingSinks.length !== 0 && 
modificationEnabled;
   }
 
-  handleReuseOperatorResultStatusChange() {
-    merge(
-      this.highlightedOperators,
-      
this.workflowActionService.getTexeraGraph().getReuseCacheOperatorsChangedStream(),
-      this.workflowActionService.getWorkflowModificationEnabledStream()
-    ).subscribe(event => {
-      const effectiveHighlightedOperatorsExcludeSink = 
this.highlightedOperators.value.filter(
-        op => 
!isSink(this.workflowActionService.getTexeraGraph().getOperator(op))
-      );
-
-      const allMarkedForReuse = 
effectiveHighlightedOperatorsExcludeSink.every(op =>
-        this.workflowActionService.getTexeraGraph().isMarkedForReuseResult(op)
-      );
-
-      this.isMarkForReuse = !allMarkedForReuse;
-      this.isReuseResultClickable =
-        effectiveHighlightedOperatorsExcludeSink.length !== 0 &&
-        this.workflowActionService.checkWorkflowModificationEnabled();
-    });
+  private highlightedOperatorIdsExcludingSinks(): string[] {
+    const texeraGraph = this.workflowActionService.getTexeraGraph();
+    return this._highlightedOperators$.value.filter(op => 
!isSink(texeraGraph.getOperator(op)));
   }
 
   /**

Reply via email to