Browse Source

Merge pull request #9930 from jtpio/debugger-evaluate

[Debugger] Basic support for evaluating code at a breakpoint
Max Klein 4 years ago
parent
commit
7cd090b60f

+ 2 - 0
packages/debugger-extension/package.json

@@ -57,7 +57,9 @@
     "@jupyterlab/debugger": "^3.1.0-alpha.3",
     "@jupyterlab/docregistry": "^3.1.0-alpha.3",
     "@jupyterlab/fileeditor": "^3.1.0-alpha.3",
+    "@jupyterlab/logconsole": "^3.1.0-alpha.3",
     "@jupyterlab/notebook": "^3.1.0-alpha.3",
+    "@jupyterlab/rendermime": "^3.1.0-alpha.3",
     "@jupyterlab/services": "^6.1.0-alpha.3",
     "@jupyterlab/settingregistry": "^3.1.0-alpha.3",
     "@jupyterlab/translation": "^3.1.0-alpha.3"

+ 76 - 7
packages/debugger-extension/src/index.ts

@@ -37,11 +37,19 @@ import { DocumentWidget } from '@jupyterlab/docregistry';
 
 import { FileEditor, IEditorTracker } from '@jupyterlab/fileeditor';
 
+import { ILoggerRegistry } from '@jupyterlab/logconsole';
+
 import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
 
+import {
+  RenderMimeRegistry,
+  standardRendererFactories as initialFactories
+} from '@jupyterlab/rendermime';
+
 import { Session } from '@jupyterlab/services';
 
 import { ISettingRegistry } from '@jupyterlab/settingregistry';
+
 import { ITranslator } from '@jupyterlab/translation';
 
 /**
@@ -361,7 +369,8 @@ const sidebar: JupyterFrontEndPlugin<IDebugger.ISidebar> = {
       terminate: CommandIDs.terminate,
       next: CommandIDs.next,
       stepIn: CommandIDs.stepIn,
-      stepOut: CommandIDs.stepOut
+      stepOut: CommandIDs.stepOut,
+      evaluate: CommandIDs.evaluate
     };
 
     const sidebar = new Debugger.Sidebar({
@@ -397,19 +406,26 @@ const sidebar: JupyterFrontEndPlugin<IDebugger.ISidebar> = {
  */
 const main: JupyterFrontEndPlugin<void> = {
   id: '@jupyterlab/debugger-extension:main',
-  requires: [IDebugger, IEditorServices, ITranslator, IDebuggerSidebar],
-  optional: [ILabShell, ILayoutRestorer, ICommandPalette, IDebuggerSources],
+  requires: [IDebugger, IDebuggerSidebar, IEditorServices, ITranslator],
+  optional: [
+    ICommandPalette,
+    IDebuggerSources,
+    ILabShell,
+    ILayoutRestorer,
+    ILoggerRegistry
+  ],
   autoStart: true,
   activate: async (
     app: JupyterFrontEnd,
     service: IDebugger,
+    sidebar: IDebugger.ISidebar,
     editorServices: IEditorServices,
     translator: ITranslator,
-    sidebar: IDebugger.ISidebar,
+    palette: ICommandPalette | null,
+    debuggerSources: IDebugger.ISources | null,
     labShell: ILabShell | null,
     restorer: ILayoutRestorer | null,
-    palette: ICommandPalette | null,
-    debuggerSources: IDebugger.ISources | null
+    loggerRegistry: ILoggerRegistry | null
   ): Promise<void> => {
     const trans = translator.load('jupyterlab');
     const { commands, shell, serviceManager } = app;
@@ -435,6 +451,58 @@ const main: JupyterFrontEndPlugin<void> = {
       }
     }
 
+    // get the mime type of the kernel language for the current debug session
+    const getMimeType = async (): Promise<string> => {
+      const kernel = service.session?.connection?.kernel;
+      if (!kernel) {
+        return '';
+      }
+      const info = (await kernel.info).language_info;
+      const name = info.name;
+      const mimeType =
+        editorServices?.mimeTypeService.getMimeTypeByLanguage({ name }) ?? '';
+      return mimeType;
+    };
+
+    const rendermime = new RenderMimeRegistry({ initialFactories });
+
+    commands.addCommand(CommandIDs.evaluate, {
+      label: trans.__('Evaluate Code'),
+      caption: trans.__('Evaluate Code'),
+      icon: Debugger.Icons.evaluateIcon,
+      isEnabled: () => {
+        return service.hasStoppedThreads();
+      },
+      execute: async () => {
+        const mimeType = await getMimeType();
+        const result = await Debugger.Dialogs.getCode({
+          title: trans.__('Evaluate Code'),
+          okLabel: trans.__('Evaluate'),
+          cancelLabel: trans.__('Cancel'),
+          mimeType,
+          rendermime
+        });
+        const code = result.value;
+        if (!result.button.accept || !code) {
+          return;
+        }
+        const reply = await service.evaluate(code);
+        if (reply) {
+          const data = reply.result;
+          const path = service?.session?.connection?.path;
+          const logger = path ? loggerRegistry?.getLogger?.(path) : undefined;
+
+          if (logger) {
+            // print to log console of the notebook currently being debugged
+            logger.log({ type: 'text', data, level: logger.level });
+          } else {
+            // fallback to printing to devtools console
+            console.debug(data);
+          }
+        }
+      }
+    });
+
     commands.addCommand(CommandIDs.debugContinue, {
       label: trans.__('Continue'),
       caption: trans.__('Continue'),
@@ -524,7 +592,8 @@ const main: JupyterFrontEndPlugin<void> = {
         CommandIDs.terminate,
         CommandIDs.next,
         CommandIDs.stepIn,
-        CommandIDs.stepOut
+        CommandIDs.stepOut,
+        CommandIDs.evaluate
       ].forEach(command => {
         palette.addItem({ command, category });
       });

+ 2 - 0
packages/debugger-extension/style/index.css

@@ -6,9 +6,11 @@
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
 @import url('~@jupyterlab/apputils/style/index.css');
 @import url('~@jupyterlab/codeeditor/style/index.css');
+@import url('~@jupyterlab/rendermime/style/index.css');
 @import url('~@jupyterlab/docregistry/style/index.css');
 @import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/console/style/index.css');
 @import url('~@jupyterlab/fileeditor/style/index.css');
 @import url('~@jupyterlab/notebook/style/index.css');
 @import url('~@jupyterlab/debugger/style/index.css');
+@import url('~@jupyterlab/logconsole/style/index.css');

+ 2 - 0
packages/debugger-extension/style/index.js

@@ -6,9 +6,11 @@
 /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */
 import '@jupyterlab/apputils/style/index.js';
 import '@jupyterlab/codeeditor/style/index.js';
+import '@jupyterlab/rendermime/style/index.js';
 import '@jupyterlab/docregistry/style/index.js';
 import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/console/style/index.js';
 import '@jupyterlab/fileeditor/style/index.js';
 import '@jupyterlab/notebook/style/index.js';
 import '@jupyterlab/debugger/style/index.js';
+import '@jupyterlab/logconsole/style/index.js';

+ 6 - 0
packages/debugger-extension/tsconfig.json

@@ -30,9 +30,15 @@
     {
       "path": "../fileeditor"
     },
+    {
+      "path": "../logconsole"
+    },
     {
       "path": "../notebook"
     },
+    {
+      "path": "../rendermime"
+    },
     {
       "path": "../services"
     },

+ 2 - 0
packages/debugger/package.json

@@ -60,6 +60,7 @@
     "@jupyterlab/fileeditor": "^3.1.0-alpha.3",
     "@jupyterlab/notebook": "^3.1.0-alpha.3",
     "@jupyterlab/observables": "^4.1.0-alpha.3",
+    "@jupyterlab/rendermime": "^3.1.0-alpha.3",
     "@jupyterlab/services": "^6.1.0-alpha.3",
     "@jupyterlab/translation": "^3.1.0-alpha.3",
     "@jupyterlab/ui-components": "^3.1.0-alpha.3",
@@ -68,6 +69,7 @@
     "@lumino/coreutils": "^1.5.3",
     "@lumino/datagrid": "^0.20.0",
     "@lumino/disposable": "^1.4.3",
+    "@lumino/messaging": "^1.4.3",
     "@lumino/signaling": "^1.4.3",
     "@lumino/widgets": "^1.19.0",
     "codemirror": "~5.57.0",

+ 16 - 1
packages/debugger/src/debugger.ts

@@ -1,12 +1,14 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { runIcon, stopIcon } from '@jupyterlab/ui-components';
+import { codeIcon, runIcon, stopIcon } from '@jupyterlab/ui-components';
 
 import { EditorHandler as DebuggerEditorHandler } from './handlers/editor';
 
 import { DebuggerConfig } from './config';
 
+import { DebuggerEvaluateDialog } from './dialogs/evaluate';
+
 import { ReadOnlyEditorFactory as EditorFactory } from './factory';
 
 import { DebuggerHandler } from './handler';
@@ -101,6 +103,8 @@ export namespace Debugger {
     export const stepOut = 'debugger:stepOut';
 
     export const inspectVariable = 'debugger:inspect-variable';
+
+    export const evaluate = 'debugger:evaluate';
   }
 
   /**
@@ -108,6 +112,7 @@ export namespace Debugger {
    */
   export namespace Icons {
     export const closeAllIcon = closeAll;
+    export const evaluateIcon = codeIcon;
     export const continueIcon = runIcon;
     export const stepIntoIcon = stepInto;
     export const stepOutIcon = stepOut;
@@ -116,4 +121,14 @@ export namespace Debugger {
     export const variableIcon = variable;
     export const viewBreakpointIcon = viewBreakpoint;
   }
+
+  /**
+   * The debugger dialog helpers.
+   */
+  export namespace Dialogs {
+    /**
+     * Open a code prompt in a dialog.
+     */
+    export const getCode = DebuggerEvaluateDialog.getCode;
+  }
 }

+ 132 - 0
packages/debugger/src/dialogs/evaluate.ts

@@ -0,0 +1,132 @@
+import { Dialog } from '@jupyterlab/apputils';
+
+import { CodeCell, CodeCellModel } from '@jupyterlab/cells';
+
+import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
+
+import { Message } from '@lumino/messaging';
+
+import { Widget } from '@lumino/widgets';
+
+/**
+ * A namespace for DebuggerEvaluateDialog statics.
+ */
+export namespace DebuggerEvaluateDialog {
+  /**
+   * Instantiation options for the evaluate dialog.
+   */
+  export interface IOptions {
+    /**
+     * The top level text for the dialog. Defaults to an empty string.
+     */
+    title: string;
+
+    /**
+     * The mime renderer for the cell widget.
+     */
+    rendermime: IRenderMimeRegistry;
+
+    /**
+     * The mime type for the cell widget content.
+     */
+    mimeType?: string;
+
+    /**
+     * Label for ok button.
+     */
+    okLabel?: string;
+
+    /**
+     * Label for cancel button.
+     */
+    cancelLabel?: string;
+  }
+
+  /**
+   * Create and show a dialog to prompt user for code.
+   *
+   * @param options - The dialog setup options.
+   *
+   * @returns A promise that resolves with whether the dialog was accepted
+   */
+  export function getCode(options: IOptions): Promise<Dialog.IResult<string>> {
+    const dialog = new EvaluateDialog({
+      ...options,
+      body: new EvaluateDialogBody(options),
+      buttons: [
+        Dialog.cancelButton({ label: options.cancelLabel }),
+        Dialog.okButton({ label: options.okLabel })
+      ]
+    });
+    return dialog.launch();
+  }
+}
+
+/**
+ * A dialog to prompt users for code to evaluate.
+ */
+class EvaluateDialog extends Dialog<string> {
+  /**
+   * Handle the DOM events for the Evaluate dialog.
+   *
+   * @param event The DOM event sent to the dialog widget
+   */
+  handleEvent(event: Event): void {
+    if (event.type === 'keydown') {
+      const keyboardEvent = event as KeyboardEvent;
+      const { code, shiftKey } = keyboardEvent;
+      if (shiftKey && code === 'Enter') {
+        return this.resolve();
+      }
+      if (code === 'Enter') {
+        return;
+      }
+    }
+    super.handleEvent(event);
+  }
+}
+
+/**
+ * Widget body with a code cell prompt in a dialog
+ */
+class EvaluateDialogBody extends Widget implements Dialog.IBodyWidget<string> {
+  /**
+   * CodePromptDialog constructor
+   *
+   * @param options Constructor options
+   */
+  constructor(options: DebuggerEvaluateDialog.IOptions) {
+    super();
+
+    const { rendermime, mimeType } = options;
+
+    const model = new CodeCellModel({});
+    model.mimeType = mimeType ?? '';
+    this._prompt = new CodeCell({
+      rendermime,
+      model
+    }).initializeState();
+
+    // explicitely remove the prompt in front of the input area
+    this._prompt.inputArea.promptNode.remove();
+
+    this.node.appendChild(this._prompt.node);
+  }
+
+  /**
+   * Get the text specified by the user
+   */
+  getValue(): string {
+    return this._prompt.model.value.text;
+  }
+
+  /**
+   *  A message handler invoked on an `'after-attach'` message.
+   */
+  protected onAfterAttach(msg: Message): void {
+    super.onAfterAttach(msg);
+    this._prompt.activate();
+  }
+
+  private _prompt: CodeCell;
+}

+ 13 - 0
packages/debugger/src/panels/callstack/index.ts

@@ -71,6 +71,14 @@ export class Callstack extends Panel {
       })
     );
 
+    header.toolbar.addItem(
+      'evaluate',
+      new CommandToolbarButton({
+        commands: commands.registry,
+        id: commands.evaluate
+      })
+    );
+
     this.addWidget(header);
     this.addWidget(body);
 
@@ -115,6 +123,11 @@ export namespace Callstack {
      * The stepOut command ID.
      */
     stepOut: string;
+
+    /**
+     * The evaluate command ID.
+     */
+    evaluate: string;
   }
 
   /**

+ 27 - 0
packages/debugger/src/service.ts

@@ -216,6 +216,33 @@ export class DebuggerService implements IDebugger, IDisposable {
     return { ...reply.body, path: source.path ?? '' };
   }
 
+  /**
+   * Evaluate an expression.
+   *
+   * @param expression The expression to evaluate as a string.
+   */
+  async evaluate(
+    expression: string
+  ): Promise<DebugProtocol.EvaluateResponse['body'] | null> {
+    if (!this.session) {
+      throw new Error('No active debugger session');
+    }
+    const frameId = this.model.callstack.frame?.id;
+    const reply = await this.session.sendRequest('evaluate', {
+      context: 'repl',
+      expression,
+      frameId
+    });
+    if (!reply.success) {
+      return null;
+    }
+    // get the frames to retrieve the latest state of the variables
+    this._clearModel();
+    await this._getAllFrames();
+
+    return reply.body;
+  }
+
   /**
    * Makes the current thread run again for one step.
    */

+ 7 - 0
packages/debugger/src/tokens.ts

@@ -54,6 +54,13 @@ export interface IDebugger {
    */
   continue(): Promise<void>;
 
+  /**
+   * Evaluate an expression.
+   */
+  evaluate(
+    expression: string
+  ): Promise<DebugProtocol.EvaluateResponse['body'] | null>;
+
   /**
    * Computes an id based on the given code.
    */

+ 1 - 0
packages/debugger/style/index.css

@@ -9,6 +9,7 @@
 @import url('~@jupyterlab/apputils/style/index.css');
 @import url('~@jupyterlab/codeeditor/style/index.css');
 @import url('~@jupyterlab/codemirror/style/index.css');
+@import url('~@jupyterlab/rendermime/style/index.css');
 @import url('~@jupyterlab/docregistry/style/index.css');
 @import url('~@jupyterlab/application/style/index.css');
 @import url('~@jupyterlab/cells/style/index.css');

+ 1 - 0
packages/debugger/style/index.js

@@ -9,6 +9,7 @@ import '@jupyterlab/ui-components/style/index.js';
 import '@jupyterlab/apputils/style/index.js';
 import '@jupyterlab/codeeditor/style/index.js';
 import '@jupyterlab/codemirror/style/index.js';
+import '@jupyterlab/rendermime/style/index.js';
 import '@jupyterlab/docregistry/style/index.js';
 import '@jupyterlab/application/style/index.js';
 import '@jupyterlab/cells/style/index.js';

+ 3 - 2
packages/debugger/test/debugger.spec.ts

@@ -102,7 +102,8 @@ describe('Debugger', () => {
         terminate: '',
         next: '',
         stepIn: '',
-        stepOut: ''
+        stepOut: '',
+        evaluate: ''
       },
       editorServices: {
         factoryService,
@@ -167,7 +168,7 @@ describe('Debugger', () => {
       const node = sidebar.callstack.node;
       const items = node.querySelectorAll('button');
 
-      expect(items.length).toEqual(5);
+      expect(items.length).toEqual(6);
       items.forEach(item => {
         expect(Array.from(items[0].classList)).toEqual(
           expect.arrayContaining(['jp-ToolbarButtonComponent'])

+ 3 - 0
packages/debugger/tsconfig.json

@@ -39,6 +39,9 @@
     {
       "path": "../observables"
     },
+    {
+      "path": "../rendermime"
+    },
     {
       "path": "../services"
     },

+ 6 - 0
packages/debugger/tsconfig.test.json

@@ -35,6 +35,9 @@
     {
       "path": "../observables"
     },
+    {
+      "path": "../rendermime"
+    },
     {
       "path": "../services"
     },
@@ -83,6 +86,9 @@
     {
       "path": "../observables"
     },
+    {
+      "path": "../rendermime"
+    },
     {
       "path": "../services"
     },