浏览代码

Merge branch 'session' into debugger-ui

Borys Palka 5 年之前
父节点
当前提交
b7551a1776
共有 14 个文件被更改,包括 551 次插入211 次删除
  1. 19 0
      DESIGN.md
  2. 2 6
      README.md
  3. 1 2
      azure-pipelines.yml
  4. 10 10
      package.json
  5. 11 163
      src/breakpoints/index.ts
  6. 81 0
      src/breakpointsService.ts
  7. 7 9
      src/debugger.ts
  8. 16 9
      src/index.ts
  9. 193 0
      src/notebookTracker/index.ts
  10. 22 2
      src/session.ts
  11. 3 3
      src/sidebar.ts
  12. 1 0
      src/tokens.ts
  13. 21 5
      tests/run-test.py
  14. 164 2
      tests/src/session.spec.ts

+ 19 - 0
DESIGN.md

@@ -0,0 +1,19 @@
+# `@jupyterlab/debugger` user interactions and experience
+
+- The debugger UI will only ever exist as a single instance within JupyterLab:
+  - An expanded mode which is a `MainAreaWidget<Debugger>`
+    - In expanded mode, the debugger will contain a tab panel for text editors that are launched when a user steps into code that has been stopped via breakpoints.
+    - If the user adds a breakpoint and steps into code from the sidebar, then it should automatically switch to expanded mode to accommodate displaying code files.
+    - Code files in the debugger will _always_ be read-only. They should support adding breakpoints.
+  - A condensed mode which is a condensed sidebar view of the debugger within the `right` or `left` areas of the JupyterLab shell
+- The debugger will support debugging and inspecting environment for three types of activities in JupyterLab:
+  1. Notebooks
+  1. Code consoles
+  1. Text editors that are backed by a kernel
+- A `JupyterFrontendPlugin` will exist to track each of the activities and each time the `currentChanged` signal fires on one of the trackers, the debugger UI will reflect the state of that activity, _e.g._:
+  - If a user has a single open notebook and opens the debugger, it will open either "docked" in the sidebar or in the main area depending on the last known position of the debugger.
+  - If the user then opens a new notebook or switches to a code console, then the debugger will update to show the state of the newly focused kernel.
+- The debugger should be state-less insofar as it can arbitrarily switch to displaying the breakpoints and variables of a new kernel based on a user switching from one notebook to another, _etc._:
+  - The debugger should automatically start a debugging session with the kernel transparently without end-user intervention.
+  - Any UI information that cannot be retrieved from the kernel needs to be stored in a different channel (for example, inside a `StateDB`) or discarded. In particular, if the kernel cannot return a list of breakpoints that have been set, then it becomes the debugger UI's responsibility to rehydrate and dehydrate these as needed.
+  - When the application is `restored` or when a debugger UI is instantiated, it should appear docked or expanded (this can perhaps be `type Debugger.Mode = 'condensed' | 'expanded'`) based on its last known state from a previous session.

+ 2 - 6
README.md

@@ -7,19 +7,17 @@ This extension is under active development and is not yet available.
 ## Prerequisites
 
 - JupyterLab 1.1+
+- xeus-python 0.5+
 
 ## Development
 
 ```bash
 # Create a new conda environment
-conda create -n jupyterlab-debugger -c conda-forge jupyterlab nodejs xeus-python ptvsd
+conda create -n jupyterlab-debugger -c conda-forge jupyterlab nodejs xeus-python=0.5 ptvsd
 
 # Activate the conda environment
 conda activate jupyterlab-debugger
 
-# Create a directory for the kernel debug logs in the folder where JupyterLab is started
-mkdir xpython_debug_logs
-
 # Install dependencies
 jlpm
 
@@ -41,8 +39,6 @@ XEUS_LOG=1 jupyter lab --no-browser --watch
 
 ### Tests
 
-Make sure `xeus-python` is installed and `jupyter --paths` points to where the kernel is installed.
-
 To run the tests:
 
 ```bash

+ 1 - 2
azure-pipelines.yml

@@ -16,7 +16,7 @@ steps:
 
 - bash: |
     source activate jupyterlab-debugger
-    conda install --yes --quiet -c conda-forge nodejs xeus-python ptvsd python=$PYTHON_VERSION
+    conda install --yes --quiet -c conda-forge nodejs xeus-python=0.5 ptvsd python=$PYTHON_VERSION
     python -m pip install -U --pre jupyterlab
   displayName: Install dependencies
 
@@ -28,7 +28,6 @@ steps:
 
 - bash: |
     source activate jupyterlab-debugger
-    export JUPYTER_PATH=${CONDA_PREFIX}/share/jupyter
     export XEUS_LOG=1
     jlpm run test
   displayName: Run the tests

+ 10 - 10
package.json

@@ -39,15 +39,15 @@
     "watch": "tsc -b --watch"
   },
   "dependencies": {
-    "@jupyterlab/application": "^1.1.0-alpha.1",
-    "@jupyterlab/apputils": "^1.1.0-alpha.1",
-    "@jupyterlab/codeeditor": "^1.1.0-alpha.1",
-    "@jupyterlab/console": "^1.1.0-alpha.1",
-    "@jupyterlab/coreutils": "^3.1.0-alpha.1",
-    "@jupyterlab/fileeditor": "^1.1.0-alpha.1",
-    "@jupyterlab/launcher": "^1.1.0-alpha.1",
-    "@jupyterlab/notebook": "^1.1.0-alpha.1",
-    "@jupyterlab/services": "^4.1.0-alpha.1",
+    "@jupyterlab/application": "^1.1.0",
+    "@jupyterlab/apputils": "^1.1.0",
+    "@jupyterlab/codeeditor": "^1.1.0",
+    "@jupyterlab/console": "^1.1.0",
+    "@jupyterlab/coreutils": "^3.1.0",
+    "@jupyterlab/fileeditor": "^1.1.0",
+    "@jupyterlab/launcher": "^1.1.0",
+    "@jupyterlab/notebook": "^1.1.0",
+    "@jupyterlab/services": "^4.1.0",
     "@phosphor/algorithm": "^1.2.0",
     "@phosphor/coreutils": "^1.3.1",
     "@phosphor/disposable": "^1.2.0",
@@ -58,7 +58,7 @@
   "devDependencies": {
     "@babel/core": "^7.5.5",
     "@babel/preset-env": "^7.5.5",
-    "@jupyterlab/testutils": "^1.1.0-alpha.1",
+    "@jupyterlab/testutils": "^1.1.0",
     "@types/chai": "^4.1.3",
     "@types/jest": "^24.0.17",
     "chai": "^4.2.0",

+ 11 - 163
src/breakpoints/index.ts

@@ -7,15 +7,18 @@ import { Widget, Panel, PanelLayout } from '@phosphor/widgets';
 import { DebugProtocol } from 'vscode-debugprotocol';
 import { Body } from './body';
 import { Signal, ISignal } from '@phosphor/signaling';
-import { INotebookTracker } from '@jupyterlab/notebook';
-import { CodeMirrorEditor } from '@jupyterlab/codemirror';
-import { Editor, Doc } from 'codemirror';
-import { CodeCell } from '@jupyterlab/cells';
+import { BreakpointsService } from '../breakpointsService';
 
 export class Breakpoints extends Panel {
   constructor(options: Breakpoints.IOptions) {
     super();
 
+    this.service = options.service;
+
+    this.service.selectedBreakpointsChanged.connect((sender, update) => {
+      this.model.breakpoints = update;
+    });
+
     this.model = new Breakpoints.IModel([]);
     this.addClass('jp-DebuggerBreakpoints');
     this.title.label = 'Breakpoints';
@@ -46,160 +49,17 @@ export class Breakpoints extends Panel {
       new ToolbarButton({
         iconClassName: 'jp-CloseAllIcon',
         onClick: () => {
-          this.model.breakpoints = [];
-          this.cellsBreakpoints[this.getCell().id] = [];
-          this.removeAllGutterBreakpoints(this.getCell());
+          this.service.clearSelectedBreakpoints();
         },
         tooltip: 'Remove All Breakpoints'
       })
     );
-
-    this.noteTracker = options.noteTracker;
-    if (this.noteTracker) {
-      this.noteTracker.activeCellChanged.connect(
-        this.onActiveCellChanged,
-        this
-      );
-    }
   }
 
   private isAllActive = true;
   readonly body: Widget;
   readonly model: Breakpoints.IModel;
-  noteTracker: INotebookTracker;
-  previousCell: CodeCell;
-  previousLineCount: number;
-  cellsBreakpoints: { [id: string]: Breakpoints.IBreakpoint[] } = {};
-
-  protected onActiveCellChanged() {
-    const activeCell = this.getCell();
-    if (this.model && activeCell) {
-      if (this.previousCell && !this.previousCell.isDisposed) {
-        this.removeListner(this.previousCell);
-      }
-      this.previousCell = activeCell;
-      const id: string = activeCell.model.id;
-      if (id && !this.cellsBreakpoints[id]) {
-        this.cellsBreakpoints[id] = [];
-      }
-      this.model.breakpoints = this.cellsBreakpoints[id];
-      this.setEditor(activeCell);
-    }
-  }
-
-  protected getCell(): CodeCell {
-    return this.noteTracker.activeCell as CodeCell;
-  }
-
-  protected removeAllGutterBreakpoints(cell: CodeCell) {
-    const editor = cell.editor as CodeMirrorEditor;
-    editor.editor.getDoc().eachLine(line => {
-      editor.editor.setGutterMarker(line, 'breakpoints', null);
-    });
-  }
-
-  removeListner(cell: CodeCell) {
-    const editor = cell.editor as CodeMirrorEditor;
-    this.cellsBreakpoints[cell.model.id] = this.model.breakpoints;
-    this.model.breakpoints = [];
-    editor.setOption('lineNumbers', false);
-    editor.editor.off('gutterClick', this.onGutterClick);
-    editor.editor.off('renderLine', this.onNewRenderLine);
-  }
-
-  setEditor(cell: CodeCell) {
-    if (!cell || !cell.editor) {
-      return;
-    }
-
-    const editor = cell.editor as CodeMirrorEditor;
-    editor.setOption('lineNumbers', true);
-    editor.editor.setOption('gutters', [
-      'CodeMirror-linenumbers',
-      'breakpoints'
-    ]);
-
-    editor.editor.on('gutterClick', this.onGutterClick);
-    editor.editor.on('renderLine', this.onNewRenderLine);
-  }
-
-  protected onNewRenderLine = (editor: Editor, line: any) => {
-    const lineInfo = editor.lineInfo(line);
-    if (
-      !this.model.breakpoints &&
-      this.model.breakpoints.length < 1 &&
-      lineInfo.handle &&
-      lineInfo.handle.order === false
-    ) {
-      return;
-    }
-
-    const doc: Doc = editor.getDoc();
-    const linesNumber = doc.lineCount();
-
-    if (this.previousLineCount !== linesNumber) {
-      if (this.previousLineCount > linesNumber) {
-        this.model.changeLines(lineInfo.line, -1);
-      }
-      if (this.previousLineCount < linesNumber) {
-        this.model.changeLines(lineInfo.line, +1);
-      }
-      this.previousLineCount = linesNumber;
-    }
-    // eage case for backspace line 2
-    if (lineInfo.line === 0) {
-      const breakpoint: Breakpoints.IBreakpoint = this.model.getBreakpointByLineNumber(
-        -1
-      );
-      if (breakpoint) {
-        this.model.removeBreakpoint(breakpoint);
-      }
-    }
-  };
-
-  private addBreakpoint(line: number) {
-    this.model.breakpoint = {
-      id: this.model.breakpoints.length + 1,
-      active: true,
-      verified: true,
-      source: {
-        // TODO: need get filename
-        name: 'untitled.py'
-      },
-      line: line
-    };
-  }
-
-  protected onGutterClick = (editor: Editor, lineNumber: number) => {
-    const info = editor.lineInfo(lineNumber);
-    if (!info) {
-      return;
-    }
-    const isRemoveGutter = !!info.gutterMarkers;
-
-    const breakpoint: Breakpoints.IBreakpoint = this.model.getBreakpointByLineNumber(
-      lineNumber
-    );
-
-    if (!breakpoint && !isRemoveGutter) {
-      this.addBreakpoint(lineNumber);
-    } else if (isRemoveGutter) {
-      this.model.removeBreakpoint(breakpoint);
-    }
-
-    editor.setGutterMarker(
-      lineNumber,
-      'breakpoints',
-      isRemoveGutter ? null : this.createMarkerNode()
-    );
-  };
-
-  createMarkerNode() {
-    var marker = document.createElement('div');
-    marker.className = 'jp-breakpoint-marker';
-    marker.innerHTML = '●';
-    return marker;
-  }
+  service: BreakpointsService;
 }
 class BreakpointsHeader extends Widget {
   constructor(title: string) {
@@ -246,7 +106,7 @@ export namespace Breakpoints {
 
     set breakpoints(breakpoints: IBreakpoint[]) {
       this._state = breakpoints;
-      this._breakpointsChanged.emit(this._state);
+      this._breakpointsChanged.emit(breakpoints);
     }
 
     set breakpoint(breakpoint: IBreakpoint) {
@@ -266,18 +126,6 @@ export namespace Breakpoints {
       this.breakpoints = breakpoints;
     }
 
-    changeLines(lineEnter: number, howMany: number) {
-      const breakpoints = this.breakpoints.map(ele => {
-        ele.line = lineEnter <= ele.line ? ele.line + howMany : ele.line;
-        return ele;
-      });
-      this.breakpoints = breakpoints;
-    }
-
-    getBreakpointByLineNumber(lineNumber: number) {
-      return this.breakpoints.find(ele => ele.line === lineNumber);
-    }
-
     private _state: IBreakpoint[];
     private _breakpointsChanged = new Signal<this, IBreakpoint[]>(this);
     private _breakpointChanged = new Signal<this, IBreakpoint>(this);
@@ -287,6 +135,6 @@ export namespace Breakpoints {
    * Instantiation options for `Breakpoints`;
    */
   export interface IOptions extends Panel.IOptions {
-    noteTracker?: INotebookTracker;
+    service?: BreakpointsService;
   }
 }

+ 81 - 0
src/breakpointsService.ts

@@ -0,0 +1,81 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { Breakpoints } from './breakpoints';
+import { Signal } from '@phosphor/signaling';
+import { LineInfo } from './notebookTracker';
+
+export class BreakpointsService {
+  constructor() {}
+
+  state: any = {};
+  selectedBreakpoints: Breakpoints.IBreakpoint[] = [];
+
+  selectedBreakpointsChanged = new Signal<this, Breakpoints.IBreakpoint[]>(
+    this
+  );
+  breakpointChanged = new Signal<this, Breakpoints.IBreakpoint>(this);
+
+  addBreakpoint(session_id: string, editor_id: string, lineInfo: LineInfo) {
+    const breakpoint: Breakpoints.IBreakpoint = {
+      line: lineInfo.line,
+      active: true,
+      verified: true,
+      source: {
+        name: session_id
+      }
+    };
+    this.selectedBreakpoints = [...this.selectedBreakpoints, breakpoint];
+    this.selectedBreakpointsChanged.emit(this.selectedBreakpoints);
+  }
+
+  get breakpoints() {
+    return this.selectedBreakpoints;
+  }
+
+  onSelectedBreakpoints(session_id: string, editor_id: string) {
+    if (!this.state[session_id]) {
+      this.state[session_id] = {};
+      if (!this.state[session_id][editor_id]) {
+        this.state[session_id][editor_id] = [];
+      }
+    } else {
+      if (!this.state[session_id][editor_id]) {
+        this.state[session_id][editor_id] = [];
+      }
+    }
+  }
+
+  removeBreakpoint(session_id: any, editor_id: any, lineInfo: any) {
+    this.selectedBreakpoints = this.selectedBreakpoints.filter(
+      ele => ele.line !== lineInfo.line
+    );
+    this.selectedBreakpointsChanged.emit(this.selectedBreakpoints);
+  }
+
+  getBreakpointState(session_id: any, editor_id: any, lineInfo: any) {}
+
+  setBreakpointState(session_id: any, editor_id: any, lineInfo: any) {}
+
+  clearSelectedBreakpoints() {
+    this.selectedBreakpoints = [];
+    this.selectedBreakpointsChanged.emit([]);
+  }
+
+  changeLines(lineInfo: LineInfo, sign: number) {
+    // need better way, maybe just look to gutter in editor?
+    const breakpoints = this.selectedBreakpoints.map(ele => {
+      if (
+        ele.line > lineInfo.line ||
+        (lineInfo.text === '' && lineInfo.line === ele.line)
+      ) {
+        ele.line = ele.line + sign;
+      }
+      if (ele.line > 0) {
+        return ele;
+      }
+    });
+    this.selectedBreakpoints = [...breakpoints];
+    this.selectedBreakpointsChanged.emit(this.selectedBreakpoints);
+  }
+}

+ 7 - 9
src/debugger.ts

@@ -11,13 +11,11 @@ import { IDisposable } from '@phosphor/disposable';
 
 import { DebuggerSidebar } from './sidebar';
 import { INotebookTracker } from '@jupyterlab/notebook';
-import { CodeCell } from '@jupyterlab/cells';
-import { IEditorTracker } from '@jupyterlab/fileeditor';
+import { BreakpointsService } from './breakpointsService';
 
 export class Debugger extends BoxPanel {
   constructor(options: Debugger.IOptions) {
     super({ direction: 'left-to-right' });
-
     this.model = new Debugger.Model(options);
     this.sidebar = new DebuggerSidebar(this.model);
     this.title.label = 'Debugger';
@@ -30,8 +28,6 @@ export class Debugger extends BoxPanel {
 
   readonly sidebar: DebuggerSidebar;
 
-  previousCell: CodeCell;
-
   dispose(): void {
     if (this.isDisposed) {
       return;
@@ -47,16 +43,15 @@ export class Debugger extends BoxPanel {
 export namespace Debugger {
   export interface IOptions {
     connector?: IDataConnector<ReadonlyJSONValue>;
-    noteTracker?: INotebookTracker;
-    editorTracker?: IEditorTracker;
     id?: string;
+    breakpointsService?: BreakpointsService;
   }
 
   export class Model implements IDisposable {
     constructor(options: Debugger.Model.IOptions) {
       this.connector = options.connector || null;
+      this.breakpointsService = options.breakpointsService;
       this.id = options.id || UUID.uuid4();
-      this._notebook = options.noteTracker;
       void this._populate();
     }
 
@@ -86,9 +81,12 @@ export namespace Debugger {
 
     private _isDisposed = false;
     private _notebook: INotebookTracker;
+    breakpointsService: BreakpointsService;
   }
 
   export namespace Model {
-    export interface IOptions extends Debugger.IOptions {}
+    export interface IOptions extends Debugger.IOptions {
+      breakpointsService?: BreakpointsService;
+    }
   }
 }

+ 16 - 9
src/index.ts

@@ -17,13 +17,15 @@ import { IStateDB } from '@jupyterlab/coreutils';
 
 import { IEditorTracker } from '@jupyterlab/fileeditor';
 
-import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
+import { INotebookTracker } from '@jupyterlab/notebook';
 
 import { Debugger } from './debugger';
 
 // import { DebuggerSidebar } from './sidebar';
 
 import { IDebugger, IDebuggerSidebar } from './tokens';
+import { DebuggerNotebookTracker } from './notebookTracker';
+import { BreakpointsService } from './breakpointsService';
 
 // import { ClientSession, IClientSession } from '@jupyterlab/apputils';
 
@@ -42,6 +44,9 @@ export namespace CommandIDs {
   export const debugNotebook = 'debugger:debug-notebook';
 }
 
+// Service for controll state of breakpoints in extensione
+const service = new BreakpointsService();
+
 /**
  * A plugin that provides visual debugging support for consoles.
  */
@@ -96,8 +101,12 @@ const notebooks: JupyterFrontEndPlugin<void> = {
     notebook: INotebookTracker,
     palette: ICommandPalette
   ) => {
-    notebook.widgetAdded.connect((sender, notePanel: NotebookPanel) => {});
+    new DebuggerNotebookTracker({
+      notebookTracker: notebook,
+      breakpointService: service
+    });
 
+    // console.log(debugetNoteTracker);
     // this exist only for my test in futre will be removed
     const command: string = CommandIDs.debugNotebook;
     app.commands.addCommand(command, {
@@ -118,16 +127,12 @@ const sidebar: JupyterFrontEndPlugin<Debugger> = {
   autoStart: true,
   activate: (
     app: JupyterFrontEnd,
-    restorer: ILayoutRestorer | null,
-    notebookTracker: INotebookTracker
+    restorer: ILayoutRestorer | null
   ): Debugger => {
     const { shell } = app;
     const label = 'Environment';
     const namespace = 'jp-debugger-sidebar';
-    const sidebar = new Debugger({
-      noteTracker: notebookTracker
-    });
-
+    const sidebar = new Debugger({ breakpointsService: service });
     sidebar.id = namespace;
     sidebar.title.label = label;
     shell.add(sidebar, 'right', { activate: false });
@@ -158,7 +163,9 @@ const tracker: JupyterFrontEndPlugin<IDebugger> = {
     const tracker = new WidgetTracker<MainAreaWidget<Debugger>>({
       namespace: 'debugger'
     });
-
+    tracker.widgetUpdated.connect((_, upadete) => {
+      upadete;
+    });
     app.commands.addCommand(CommandIDs.create, {
       execute: args => {
         const id = (args.id as string) || '';

+ 193 - 0
src/notebookTracker/index.ts

@@ -0,0 +1,193 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
+import { CodeCell } from '@jupyterlab/cells';
+import { CodeMirrorEditor } from '@jupyterlab/codemirror';
+import { Editor, Doc } from 'codemirror';
+import { DebugSession } from './../session';
+import { IClientSession } from '@jupyterlab/apputils';
+import { BreakpointsService } from '../breakpointsService';
+
+export class DebuggerNotebookTracker {
+  constructor(options: DebuggerNotebookTracker.IOptions) {
+    this.breakpointService = options.breakpointService;
+    this.notebookTracker = options.notebookTracker;
+    this.notebookTracker.widgetAdded.connect(
+      (sender, notePanel: NotebookPanel) => {
+        this.newDebuggerSession(notePanel.session);
+      }
+    );
+
+    this.notebookTracker.currentChanged.connect(
+      (sender, notePanel: NotebookPanel) => {
+        this.newDebuggerSession(notePanel.session);
+      }
+    );
+
+    this.breakpointService.selectedBreakpointsChanged.connect(
+      (sender, update) => {
+        if (update && update.length === 0) {
+          this.clearGutter(this.getCell());
+        }
+      }
+    );
+  }
+
+  notebookTracker: INotebookTracker;
+  previousCell: CodeCell;
+  previousLineCount: number;
+  debuggerSession: DebugSession;
+  breakpointService: BreakpointsService;
+
+  protected async newDebuggerSession(client: IClientSession) {
+    if (this.debuggerSession) {
+      this.debuggerSession.dispose();
+    }
+
+    // create new session. Just changing client make sometimes that kernel is not attach to note
+    this.debuggerSession = new DebugSession({
+      client: client
+    });
+    await this.notebookTracker.activeCellChanged.connect(
+      this.onActiveCellChanged,
+      this
+    );
+  }
+
+  protected clearGutter(cell: CodeCell) {
+    const editor = cell.editor as CodeMirrorEditor;
+    editor.doc.eachLine(line => {
+      if ((line as LineInfo).gutterMarkers) {
+        editor.editor.setGutterMarker(line, 'breakpoints', null);
+      }
+    });
+  }
+
+  protected async onActiveCellChanged() {
+    const activeCell = this.getCell();
+    // this run before change note, consider how to resolve this
+    if (activeCell && activeCell.editor && this.debuggerSession) {
+      this.breakpointService.onSelectedBreakpoints(
+        this.debuggerSession.id,
+        this.getEditorId()
+      );
+      if (this.previousCell && !this.previousCell.isDisposed) {
+        this.removeListner(this.previousCell);
+        this.clearGutter(this.previousCell);
+        this.breakpointService.clearSelectedBreakpoints();
+      }
+      this.previousCell = activeCell;
+      this.setEditor(activeCell);
+    }
+  }
+
+  protected setEditor(cell: CodeCell) {
+    if (!cell || !cell.editor) {
+      return;
+    }
+
+    const editor = cell.editor as CodeMirrorEditor;
+
+    this.previousLineCount = editor.lineCount;
+
+    editor.setOption('lineNumbers', true);
+    editor.editor.setOption('gutters', [
+      'CodeMirror-linenumbers',
+      'breakpoints'
+    ]);
+
+    editor.editor.on('gutterClick', this.onGutterClick);
+    editor.editor.on('renderLine', this.onNewRenderLine);
+  }
+
+  protected removeListner(cell: CodeCell) {
+    const editor = cell.editor as CodeMirrorEditor;
+    editor.setOption('lineNumbers', false);
+    editor.editor.off('gutterClick', this.onGutterClick);
+    editor.editor.off('renderLine', this.onNewRenderLine);
+  }
+
+  protected getCell(): CodeCell {
+    return this.notebookTracker.activeCell as CodeCell;
+  }
+
+  getEditorId(): string {
+    return this.getCell().editor.uuid;
+  }
+
+  protected onGutterClick = (editor: Editor, lineNumber: number) => {
+    const info = editor.lineInfo(lineNumber);
+    if (!info) {
+      return;
+    }
+
+    const isRemoveGutter = !!info.gutterMarkers;
+    if (isRemoveGutter) {
+      this.breakpointService.removeBreakpoint(
+        this.debuggerSession.id,
+        this.getEditorId,
+        info as LineInfo
+      );
+    } else {
+      this.breakpointService.addBreakpoint(
+        this.debuggerSession.id,
+        this.getEditorId(),
+        info as LineInfo
+      );
+    }
+
+    editor.setGutterMarker(
+      lineNumber,
+      'breakpoints',
+      isRemoveGutter ? null : this.createMarkerNode()
+    );
+  };
+
+  protected onNewRenderLine = (editor: Editor, line: any) => {
+    const lineInfo = editor.lineInfo(line);
+    if (lineInfo.handle && lineInfo.handle.order === false) {
+      return;
+    }
+
+    const doc: Doc = editor.getDoc();
+    const linesNumber = doc.lineCount();
+
+    if (this.previousLineCount !== linesNumber) {
+      if (this.previousLineCount < linesNumber) {
+        this.breakpointService.changeLines(lineInfo, +1);
+      }
+      if (this.previousLineCount > linesNumber) {
+        this.breakpointService.changeLines(lineInfo, -1);
+      }
+      this.previousLineCount = linesNumber;
+    }
+  };
+
+  private createMarkerNode() {
+    var marker = document.createElement('div');
+    marker.className = 'jp-breakpoint-marker';
+    marker.innerHTML = '●';
+    return marker;
+  }
+}
+
+export namespace DebuggerNotebookTracker {
+  export interface IOptions {
+    notebookTracker: INotebookTracker;
+    breakpointService: BreakpointsService;
+  }
+}
+
+export interface LineInfo {
+  line: any;
+  handle: any;
+  text: string;
+  /** Object mapping gutter IDs to marker elements. */
+  gutterMarkers: any;
+  textClass: string;
+  bgClass: string;
+  wrapClass: string;
+  /** Array of line widgets attached to this line. */
+  widgets: any;
+}

+ 22 - 2
src/session.ts

@@ -21,13 +21,33 @@ export class DebugSession implements IDebugger.ISession {
    */
   constructor(options: DebugSession.IOptions) {
     this.client = options.client;
-    this.client.iopubMessage.connect(this._handleEvent, this);
   }
 
   /**
    * The client session to connect to a debugger.
    */
-  client: IClientSession;
+  private _client: IClientSession;
+
+  get id() {
+    return this.client.name;
+  }
+
+  get client(): IClientSession {
+    return this._client;
+  }
+
+  set client(client: IClientSession | null) {
+    if (this._client === client) {
+      return;
+    }
+
+    if (this._client) {
+      Signal.clearData(this._client);
+    }
+
+    this._client = client;
+    this._client.iopubMessage.connect(this._handleEvent, this);
+  }
 
   /**
    * The code editors in a debugger session.

+ 3 - 3
src/sidebar.ts

@@ -18,11 +18,11 @@ export class DebuggerSidebar extends SplitPanel {
     this.orientation = 'vertical';
     this.addClass('jp-DebuggerSidebar');
 
-    const notebook = this.model.notebookTracker;
-
     this.variables = new Variables();
     this.callstack = new Callstack();
-    this.breakpoints = new Breakpoints({ noteTracker: notebook });
+    this.breakpoints = new Breakpoints({
+      service: this.model.breakpointsService
+    });
 
     this.addWidget(this.variables);
     this.addWidget(this.callstack);

+ 1 - 0
src/tokens.ts

@@ -114,6 +114,7 @@ export namespace IDebugger {
       terminateThreads: DebugProtocol.TerminateThreadsArguments;
       threads: {};
       updateCell: IUpdateCellArguments;
+      variables: DebugProtocol.VariablesArguments;
     };
 
     /**

+ 21 - 5
tests/run-test.py

@@ -2,13 +2,29 @@
 # Distributed under the terms of the Modified BSD License.
 
 import os
-from jupyterlab.tests.test_app import run_jest, JestApp
+
+from jupyterlab.tests.test_app import JestApp
 
 HERE = os.path.realpath(os.path.dirname(__file__))
 
-if __name__ == '__main__':
-    # xeus-python requires the xpython_debug_logs folder
+
+def run(jest_dir):
     jest_app = JestApp.instance()
-    os.mkdir(os.path.join(jest_app.notebook_dir, 'xpython_debug_logs'))
+    jest_app.jest_dir = jest_dir
+    jest_app.initialize()
+    jest_app.install_kernel(
+        kernel_name='xpython',
+        kernel_spec={
+            'argv': [
+                'xpython',
+                '-f', '{connection_file}'
+            ],
+            'display_name': 'xpython',
+            'language': 'python'
+        }
+    )
+    jest_app.start()
 
-    run_jest(HERE)
+
+if __name__ == '__main__':
+    run(HERE)

+ 164 - 2
tests/src/session.spec.ts

@@ -5,7 +5,15 @@ import { expect } from 'chai';
 
 import { ClientSession, IClientSession } from '@jupyterlab/apputils';
 
-import { createClientSession } from '@jupyterlab/testutils';
+import { createClientSession, sleep } from '@jupyterlab/testutils';
+
+import { find } from '@phosphor/algorithm';
+
+import { PromiseDelegate } from '@phosphor/coreutils';
+
+import { DebugProtocol } from 'vscode-debugprotocol';
+
+import { IDebugger } from '../../lib/tokens';
 
 import { DebugSession } from '../../lib/session';
 
@@ -71,7 +79,7 @@ describe('DebugSession', () => {
       expect(reply.body.sourcePath).to.contain('.py');
     });
 
-    it('should handle replies with success false', async () => {
+    it.skip('should handle replies with success false', async () => {
       const reply = await debugSession.sendRequest('evaluate', {
         expression: 'a'
       });
@@ -81,3 +89,157 @@ describe('DebugSession', () => {
     });
   });
 });
+
+describe('protocol', () => {
+  const code = [
+    'i = 0',
+    'i += 1',
+    'i += 1',
+    'j = i**2',
+    'j += 1',
+    'print(i, j)'
+  ].join('\n');
+
+  const breakpoints: DebugProtocol.SourceBreakpoint[] = [
+    { line: 3 },
+    { line: 5 }
+  ];
+
+  let client: IClientSession;
+  let debugSession: DebugSession;
+  let threadId: number = 1;
+
+  beforeEach(async () => {
+    client = await createClientSession({
+      kernelPreference: {
+        name: 'xpython'
+      }
+    });
+    await (client as ClientSession).initialize();
+    await client.kernel.ready;
+    debugSession = new DebugSession({ client });
+    await debugSession.start();
+
+    debugSession.eventMessage.connect(
+      (sender: DebugSession, event: IDebugger.ISession.Event) => {
+        const eventName = event.event;
+        if (eventName === 'thread') {
+          const msg = event as DebugProtocol.ThreadEvent;
+          threadId = msg.body.threadId;
+        }
+      }
+    );
+
+    const reply = await debugSession.sendRequest('updateCell', {
+      cellId: 0,
+      nextId: 1,
+      code
+    });
+    await debugSession.sendRequest('setBreakpoints', {
+      breakpoints,
+      source: { path: reply.body.sourcePath },
+      sourceModified: false
+    });
+    await debugSession.sendRequest('configurationDone', {});
+
+    // trigger an execute_request
+    client.kernel.requestExecute({ code });
+
+    // TODO: handle events instead
+    await sleep(2000);
+  });
+
+  afterEach(async () => {
+    await debugSession.stop();
+    debugSession.dispose();
+    await client.shutdown();
+    client.dispose();
+  });
+
+  describe('#stackTrace', () => {
+    it('should return the correct stackframes', async () => {
+      const reply = await debugSession.sendRequest('stackTrace', {
+        threadId
+      });
+      expect(reply.success).to.be.true;
+      const stackFrames = reply.body.stackFrames;
+      expect(stackFrames.length).to.equal(2);
+      const frame = stackFrames[0];
+      // first breakpoint
+      expect(frame.line).to.equal(3);
+    });
+  });
+
+  describe('#scopes', () => {
+    it('should return the correct scopes', async () => {
+      const stackFramesReply = await debugSession.sendRequest('stackTrace', {
+        threadId
+      });
+      const frameId = stackFramesReply.body.stackFrames[0].id;
+      const scopesReply = await debugSession.sendRequest('scopes', {
+        frameId
+      });
+      const scopes = scopesReply.body.scopes;
+      expect(scopes.length).to.equal(1);
+      expect(scopes[0].name).to.equal('Locals');
+    });
+  });
+
+  const getVariables = async () => {
+    const stackFramesReply = await debugSession.sendRequest('stackTrace', {
+      threadId
+    });
+    const frameId = stackFramesReply.body.stackFrames[0].id;
+    const scopesReply = await debugSession.sendRequest('scopes', {
+      frameId
+    });
+    const scopes = scopesReply.body.scopes;
+    const variablesReference = scopes[0].variablesReference;
+    const variablesReply = await debugSession.sendRequest('variables', {
+      variablesReference
+    });
+    return variablesReply.body.variables;
+  };
+
+  describe('#variables', () => {
+    it('should return the variables and their values', async () => {
+      const variables = await getVariables();
+      expect(variables.length).to.be.greaterThan(0);
+      const i = find(variables, variable => variable.name === 'i');
+      expect(i).to.exist;
+      expect(i.type).to.equal('int');
+      expect(i.value).to.equal('1');
+    });
+  });
+
+  describe('#continue', () => {
+    it.skip('should proceed to the next breakpoint', async () => {
+      let events: string[] = [];
+      const eventsFuture = new PromiseDelegate<string[]>();
+      debugSession.eventMessage.connect((sender, event) => {
+        events.push(event.event);
+        // aggregate the next 2 debug events
+        if (events.length === 2) {
+          eventsFuture.resolve(events);
+        }
+      });
+
+      await debugSession.sendRequest('continue', { threadId });
+
+      // wait for debug events
+      const debugEvents = await eventsFuture.promise;
+      expect(debugEvents).to.deep.equal(['continued', 'stopped']);
+
+      const variables = await getVariables();
+      const i = find(variables, variable => variable.name === 'i');
+      expect(i).to.exist;
+      expect(i.type).to.equal('int');
+      expect(i.value).to.equal('2');
+
+      const j = find(variables, variable => variable.name === 'j');
+      expect(j).to.exist;
+      expect(j.type).to.equal('int');
+      expect(j.value).to.equal('4');
+    });
+  });
+});