();
+
+ mockWorkflowStatusService = jasmine.createSpyObj("WorkflowStatusService", ["getStatusUpdateStream"]);
+ mockWorkflowStatusService.getStatusUpdateStream.and.returnValue(statusUpdateStream.asObservable());
+
+ mockUdfDebugService = jasmine.createSpyObj("UdfDebugService", ["getDebugState", "doModifyBreakpoint"]);
+ mockUdfDebugService.getDebugState.and.returnValue(debugState);
+
+ await TestBed.configureTestingModule({
+ declarations: [CodeDebuggerComponent],
+ providers: [
+ { provide: WorkflowStatusService, useValue: mockWorkflowStatusService },
+ { provide: UdfDebugService, useValue: mockUdfDebugService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CodeDebuggerComponent);
+ component = fixture.componentInstance;
+
+ // Set required input properties
+ component.currentOperatorId = operatorId;
+ // Create and attach a for Monaco editor
+ const editorElement = document.createElement("div");
+ editorElement.id = "editor-container";
+ editorElement.style.width = "800px";
+ editorElement.style.height = "600px";
+ document.body.appendChild(editorElement); // Attach to document body
+
+ // Initialize the Monaco editor with the created element
+ component.monacoEditor = monaco.editor.create(editorElement, {
+ value: "function hello() {\n\tconsole.log(\"Hello, world!\");\n}",
+ language: "javascript",
+ });
+
+ // Trigger change detection to ensure view updates
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ // Clean up streams to prevent memory leaks
+ statusUpdateStream.complete();
+ });
+
+ it("should create the component", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should setup monaco breakpoint methods when state is Running", fakeAsync(() => {
+ const setupSpy = spyOn(component, "setupMonacoBreakpointMethods").and.callThrough();
+ const rerenderSpy = spyOn(component, "rerenderExistingBreakpoints").and.callThrough();
+
+ // Emit a Running state event
+ statusUpdateStream.next({
+ [operatorId]: { operatorState: OperatorState.Running, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 },
+ });
+
+ tick();
+ fixture.detectChanges(); // Trigger change detection
+
+ expect(setupSpy).toHaveBeenCalled();
+ expect(rerenderSpy).toHaveBeenCalled();
+
+ // Emit the same state again (should not trigger setup again)
+ statusUpdateStream.next({
+ [operatorId]: { operatorState: OperatorState.Running, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 },
+ });
+
+ tick();
+ fixture.detectChanges(); // Trigger change detection
+
+ expect(setupSpy).toHaveBeenCalledTimes(1); // No additional call
+ expect(rerenderSpy).toHaveBeenCalledTimes(1); // No additional call
+
+ // Emit the paused state (should not trigger setup)
+ statusUpdateStream.next({
+ [operatorId]: { operatorState: OperatorState.Paused, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 },
+ });
+
+ tick();
+ fixture.detectChanges(); // Trigger change detection
+
+ expect(setupSpy).toHaveBeenCalledTimes(1); // No additional call
+ expect(rerenderSpy).toHaveBeenCalledTimes(1); // No additional call
+
+ // Emit the running state once more (should not trigger setup)
+ statusUpdateStream.next({
+ [operatorId]: { operatorState: OperatorState.Paused, aggregatedOutputRowCount: 0, aggregatedInputRowCount: 0 },
+ });
+
+ tick();
+ fixture.detectChanges(); // Trigger change detection
+
+ expect(setupSpy).toHaveBeenCalledTimes(1); // No additional call
+ expect(rerenderSpy).toHaveBeenCalledTimes(1); // No additional call
+ }));
+
+ it("should remove monaco breakpoint methods when state changes to Uninitialized", () => {
+ const removeSpy = spyOn(component, "removeMonacoBreakpointMethods").and.callThrough();
+
+ // Emit an Uninitialized state event
+ statusUpdateStream.next({
+ [operatorId]: {
+ operatorState: OperatorState.Uninitialized,
+ aggregatedOutputRowCount: 0,
+ aggregatedInputRowCount: 0,
+ },
+ });
+
+ fixture.detectChanges(); // Trigger change detection
+
+ expect(removeSpy).toHaveBeenCalled();
+
+ // Emit the same state again (should not trigger removal again)
+ statusUpdateStream.next({
+ [operatorId]: {
+ operatorState: OperatorState.Uninitialized,
+ aggregatedOutputRowCount: 0,
+ aggregatedInputRowCount: 0,
+ },
+ });
+
+ expect(removeSpy).toHaveBeenCalledTimes(1); // No additional call
+ });
+
+ it("should call doModifyBreakpoint on left click", () => {
+ // Simulate a left click on line 1
+ component["onMouseLeftClick"](1);
+
+ // Verify that the mock service was called with the correct arguments
+ expect(mockUdfDebugService.doModifyBreakpoint).toHaveBeenCalledWith(operatorId, 1);
+ });
+
+ it("should set breakpoint condition input on right click", () => {
+ // Mock a valid decoration map
+ component.monacoBreakpoint = {
+ lineNumberAndDecorationIdMap: new Map([
+ [1, "breakpoint1"],
+ [2, "breakpoint2"],
+ ]),
+ } as any;
+
+ // Simulate a right click on line 1, it should switch to 1
+ component["onMouseRightClick"](1);
+ expect(component.breakpointConditionLine).toBe(1);
+
+ // Simulate a right click on line 3, which does not have a breakpoint. no changes should occur
+ component["onMouseRightClick"](3);
+ expect(component.breakpointConditionLine).toBe(1);
+
+ // Simulate a right click on line 2, it should switch to 2
+ component["onMouseRightClick"](2);
+ expect(component.breakpointConditionLine).toBe(2);
+
+ // Simulate a right click on line 1, it should switch to 1
+ component["onMouseRightClick"](1);
+ expect(component.breakpointConditionLine).toBe(1);
+ });
+
+ it("should reset the breakpoint condition input when closed", () => {
+ // Set a condition line and close it
+ component.breakpointConditionLine = 1;
+ component.closeBreakpointConditionInput();
+
+ expect(component.breakpointConditionLine).toBeUndefined();
+ });
+});
diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts
new file mode 100644
index 00000000000..0cc4c14ed86
--- /dev/null
+++ b/core/gui/src/app/workspace/component/code-editor-dialog/code-debugger.component.ts
@@ -0,0 +1,230 @@
+import { AfterViewInit, Component, Input, ViewChild } from "@angular/core";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { SafeStyle } from "@angular/platform-browser";
+import "@codingame/monaco-vscode-python-default-extension";
+import "@codingame/monaco-vscode-r-default-extension";
+import "@codingame/monaco-vscode-java-default-extension";
+import { isDefined } from "../../../common/util/predicate";
+import { editor } from "monaco-editor/esm/vs/editor/editor.api.js";
+import {
+ EditorMouseEvent,
+ EditorMouseTarget,
+ ModelDecorationOptions,
+ MonacoEditor,
+ Range,
+} from "monaco-breakpoints/dist/types";
+import { MonacoBreakpoint } from "monaco-breakpoints";
+import { UdfDebugService } from "../../service/operator-debug/udf-debug.service";
+import { BreakpointConditionInputComponent } from "./breakpoint-condition-input/breakpoint-condition-input.component";
+import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service";
+import { distinctUntilChanged, map } from "rxjs/operators";
+import { OperatorState } from "../../types/execute-workflow.interface";
+import MouseTargetType = editor.MouseTargetType;
+
+/**
+ * This component is the main component for the code debugger.
+ */
+@UntilDestroy()
+@Component({
+ selector: "texera-code-debugger",
+ templateUrl: "code-debugger.component.html",
+})
+export class CodeDebuggerComponent implements AfterViewInit, SafeStyle {
+ @Input() monacoEditor!: MonacoEditor;
+ @Input() currentOperatorId!: string;
+ @ViewChild(BreakpointConditionInputComponent) breakpointConditionInput!: BreakpointConditionInputComponent;
+
+ public monacoBreakpoint: MonacoBreakpoint | undefined = undefined;
+ public breakpointConditionLine: number | undefined = undefined;
+
+ constructor(
+ private udfDebugService: UdfDebugService,
+ private workflowStatusService: WorkflowStatusService
+ ) {}
+
+ ngAfterViewInit() {
+ this.registerStatusChangeHandler();
+ this.registerBreakpointRenderingHandler();
+ }
+
+ setupMonacoBreakpointMethods(editor: MonacoEditor) {
+ // mimic the enum in monaco-breakpoints
+ enum BreakpointEnum {
+ Exist,
+ }
+
+ this.monacoBreakpoint = new MonacoBreakpoint({
+ editor,
+ hoverMessage: {
+ added: {
+ value: "Click to remove the breakpoint.",
+ },
+ unAdded: {
+ value: "Click to add a breakpoint at this line.",
+ },
+ },
+ });
+ // override the default createBreakpointDecoration so that it considers
+ // 1) hovering breakpoints;
+ // 2) exist breakpoints;
+ // 3) conditional breakpoints. (conditional breakpoints are also exist breakpoints)
+ this.monacoBreakpoint["createBreakpointDecoration"] = (
+ range: Range,
+ breakpointEnum: BreakpointEnum
+ ): { range: Range; options: ModelDecorationOptions } => {
+ const condition = this.udfDebugService.getCondition(this.currentOperatorId, range.startLineNumber);
+
+ const isConditional = Boolean(condition?.trim());
+ const exists = breakpointEnum === BreakpointEnum.Exist;
+
+ const glyphMarginClassName = exists
+ ? isConditional
+ ? "monaco-conditional-breakpoint"
+ : "monaco-breakpoint"
+ : "monaco-hover-breakpoint";
+
+ return { range, options: { glyphMarginClassName } };
+ };
+
+ // override the default mouseDownDisposable to handle
+ // 1) left click to add/remove breakpoints;
+ // 2) right click to open breakpoint condition input.
+ this.monacoBreakpoint["mouseDownDisposable"]?.dispose();
+ this.monacoBreakpoint["mouseDownDisposable"] = editor.onMouseDown((evt: EditorMouseEvent) => {
+ const { type, detail, position } = { ...(evt.target as EditorMouseTarget) };
+ const model = editor.getModel()!;
+ if (model && type === MouseTargetType.GUTTER_GLYPH_MARGIN) {
+ if (detail.isAfterLines) {
+ return;
+ }
+ if (evt.event.leftButton) {
+ this.onMouseLeftClick(position.lineNumber);
+ } else {
+ this.onMouseRightClick(position.lineNumber);
+ }
+ }
+ });
+ }
+
+ removeMonacoBreakpointMethods() {
+ if (!isDefined(this.monacoBreakpoint)) {
+ return;
+ }
+ this.monacoBreakpoint["mouseDownDisposable"]?.dispose();
+ this.monacoBreakpoint.dispose();
+ }
+
+ /**
+ * This function is called when the user left clicks on the gutter of the editor.
+ * It adds or removes a breakpoint on the line number that the user clicked on.
+ * @param lineNum the line number that the user clicked on
+ * @private
+ */
+ private onMouseLeftClick(lineNum: number) {
+ // This indicates that the current position of the mouse is over the total number of lines in the editor
+ this.udfDebugService.doModifyBreakpoint(this.currentOperatorId, lineNum);
+ }
+
+ /**
+ * This function is called when the user right clicks on the gutter of the editor.
+ * It opens the breakpoint condition input for the line number that the user clicked on.
+ * @param lineNum the line number that the user clicked on
+ * @private
+ */
+ private onMouseRightClick(lineNum: number) {
+ if (!this.monacoBreakpoint!["lineNumberAndDecorationIdMap"].has(lineNum)) {
+ return;
+ }
+
+ this.breakpointConditionLine = lineNum;
+ }
+
+ closeBreakpointConditionInput() {
+ this.breakpointConditionLine = undefined;
+ }
+
+ /**
+ * This function registers a handler that listens to the changes in the lineNumToBreakpointMapping.
+ * @private
+ */
+ private registerBreakpointRenderingHandler() {
+ this.udfDebugService.getDebugState(this.currentOperatorId).observe(evt => {
+ evt.changes.keys.forEach((change, lineNum) => {
+ switch (change.action) {
+ case "add":
+ const addedValue = evt.target.get(lineNum)!;
+ if (isDefined(addedValue.breakpointId)) {
+ this.createBreakpointDecoration(Number(lineNum));
+ }
+ break;
+ case "delete":
+ const deletedValue = change.oldValue;
+ if (isDefined(deletedValue.breakpointId)) {
+ this.removeBreakpointDecoration(Number(lineNum));
+ }
+ break;
+ case "update":
+ const oldValue = change.oldValue;
+ const newValue = evt.target.get(lineNum)!;
+ if (newValue.hit) {
+ this.monacoBreakpoint?.setLineHighlight(Number(lineNum));
+ } else {
+ this.monacoBreakpoint?.removeHighlight();
+ }
+ if (oldValue.condition !== newValue.condition) {
+ // recreate the decoration with condition
+ this.removeBreakpointDecoration(Number(lineNum));
+ this.createBreakpointDecoration(Number(lineNum));
+ }
+ break;
+ }
+ });
+ });
+ }
+
+ private createBreakpointDecoration(lineNum: number) {
+ this.monacoBreakpoint,
+ endLineNumber: Number(lineNum),
+ });
+ }
+
+ private removeBreakpointDecoration(lineNum: number) {
+ const decorationId = this.monacoBreakpoint!["lineNumberAndDecorationIdMap"].get(lineNum);
+ this.monacoBreakpoint;
+ }
+
+ rerenderExistingBreakpoints() {
+ this.udfDebugService.getDebugState(this.currentOperatorId).forEach(({ breakpointId }, lineNumStr) => {
+ if (!isDefined(breakpointId)) {
+ return;
+ }
+ this.createBreakpointDecoration(Number(lineNumStr));
+ });
+ }
+
+ private registerStatusChangeHandler() {
+ this.workflowStatusService
+ .getStatusUpdateStream()
+ .pipe(
+ map(
+ event =>
+ event[this.currentOperatorId]?.operatorState === OperatorState.Running ||
+ event[this.currentOperatorId]?.operatorState === OperatorState.Paused
+ ),
+ distinctUntilChanged(),
+ untilDestroyed(this)
+ )
+ .subscribe(enable => {
+ console.log("enable", enable);
+ // Only enable the breakpoint methods if the operator is running or paused
+ if (enable) {
+ this.setupMonacoBreakpointMethods(this.monacoEditor);
+ this.rerenderExistingBreakpoints();
+ } else {
+ // for other states, remove the breakpoint methods
+ this.removeMonacoBreakpointMethods();
+ }
+ });
+ }
+}
diff --git a/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html b/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html
index 63f615a31ac..31c8ac6f699 100644
--- a/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html
+++ b/core/gui/src/app/workspace/component/code-editor-dialog/code-editor.component.html
@@ -9,7 +9,7 @@
id="title"
(focus)="onFocus()"
cdkDragHandle>
- {{languageTitle}} : {{ title }}
+ {{ languageTitle }} : {{ title }}
+
+
+
= new Subject();
- private currentOperatorId!: string;
+ public currentOperatorId!: string;
+
public title: string | undefined;
public formControl!: FormControl;
public componentRef: ComponentRef | undefined;
@@ -70,6 +71,8 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
// For "Add All Type Annotation" to show the UI individually
private userResponseSubject?: Subject;
private isMultipleVariables: boolean = false;
+ public codeDebuggerComponent!: Type | null;
+ public editorToPass!: MonacoEditor;
private generateLanguageTitle(language: string): string {
return `${language.charAt(0).toUpperCase()}${language.slice(1)} UDF`;
@@ -221,7 +224,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
filter(isDefined),
untilDestroyed(this)
)
- .subscribe((editor: IStandaloneCodeEditor) => {
+ .subscribe((editor: MonacoEditor) => {
editor.updateOptions({ readOnly: this.formControl.disabled });
if (!this.code) {
return;
@@ -236,6 +239,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
this.workflowActionService.getTexeraGraph().getSharedModelAwareness()
);
this.setupAIAssistantActions(editor);
+ this.initCodeDebuggerComponent(editor);
});
}
@@ -276,7 +280,12 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
this.editorWrapper.initAndStart(userConfig, this.editorElement.nativeElement);
}
- private setupAIAssistantActions(editor: IStandaloneCodeEditor) {
+ private initCodeDebuggerComponent(editor: MonacoEditor) {
+ this.codeDebuggerComponent = CodeDebuggerComponent;
+ this.editorToPass = editor;
+ }
+
+ private setupAIAssistantActions(editor: MonacoEditor) {
// Check if the AI provider is "openai"
this.aiAssistantService
.checkAIAssistantEnabled()
@@ -290,7 +299,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
label: "Add Type Annotation",
contextMenuGroupId: "1_modification",
contextMenuOrder: 1.0,
- run: (editor: monaco.editor.IStandaloneCodeEditor) => {
+ run: (editor: MonacoEditor) => {
// User selected code (including range and content)
const selection = editor.getSelection();
const model = editor.getModel();
@@ -314,7 +323,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
label: "Add All Type Annotations",
contextMenuGroupId: "1_modification",
contextMenuOrder: 1.1,
- run: (editor: monaco.editor.IStandaloneCodeEditor) => {
+ run: (editor: MonacoEditor) => {
const selection = editor.getSelection();
const model = editor.getModel();
if (!model || !selection) {
@@ -402,7 +411,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
private handleTypeAnnotation(
code: string,
range: monaco.Range,
- editor: monaco.editor.IStandaloneCodeEditor,
+ editor: MonacoEditor,
lineNumber: number,
allCode: string
): void {
@@ -475,11 +484,7 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
}
}
- private insertTypeAnnotations(
- editor: monaco.editor.IStandaloneCodeEditor,
- selection: monaco.Selection,
- annotations: string
- ) {
+ private insertTypeAnnotations(editor: MonacoEditor, selection: monaco.Selection, annotations: string) {
const endLineNumber = selection.endLineNumber;
const endColumn = selection.endColumn;
const insertPosition = new monaco.Position(endLineNumber, endColumn);
diff --git a/core/gui/src/app/workspace/component/menu/menu.component.ts b/core/gui/src/app/workspace/component/menu/menu.component.ts
index b671d40d954..4d370d312f8 100644
--- a/core/gui/src/app/workspace/component/menu/menu.component.ts
+++ b/core/gui/src/app/workspace/component/menu/menu.component.ts
@@ -31,6 +31,7 @@ import { NzModalService } from "ng-zorro-antd/modal";
import { ResultExportationComponent } from "../result-exportation/result-exportation.component";
import { ReportGenerationService } from "../../service/report-generation/report-generation.service";
import { ShareAccessComponent } from "src/app/dashboard/component/user/share-access/share-access.component";
+import { UdfDebugService } from "../../service/operator-debug/udf-debug.service";
/**
* MenuComponent is the top level menu bar that shows
* the Texera title and workflow execution button
diff --git a/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html b/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html
index 1ded24099e9..101dbac0f5a 100644
--- a/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html
+++ b/core/gui/src/app/workspace/component/result-panel/console-frame/console-frame.component.html
@@ -81,7 +81,7 @@
- {{entry.message}}
+ {{ entry.message }}
- {{entry.source}}
+ {{ entry.source }}
- {{(entry.timestamp.seconds * 1000 + entry.timestamp.nanos * 0.000001) | date : "M-d-yy, HH:mm:ss.SSS"}}
+ {{ (entry.timestamp.seconds * 1000 + entry.timestamp.nanos * 0.000001) | date : "M-d-yy, HH:mm:ss.SSS" }}
- {{workerIdToAbbr(entry.workerId)}}
+ {{ workerIdToAbbr(entry.workerId) }}
+