Skip to content

Commit 2a7291a

Browse files
authored
Merge pull request #421 from krassowski/fix-editor-removal
Refactor how the notebook changes are picked up, events bound and unbound
2 parents e2b95e3 + 355b5ac commit 2a7291a

File tree

6 files changed

+183
-15
lines changed

6 files changed

+183
-15
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616

1717
- handles characters that need escaping (spaces, non-ASCII characters) more
1818
robustly in files and folder names ([#403])
19+
- moving cells now triggers the document update immediately leading to immediate diagnostics update ([#421])
20+
- changing cell type to `raw` or `markdown` and then back to `code` properly unbinds/binds event handlers and updates document ([#421])
21+
- pasted cells are added to the LSP document immediately, without the need for the user to enter them ([#421])
1922

2023
[#403]: https://github.com/krassowski/jupyterlab-lsp/issues/403
24+
[#421]: https://github.com/krassowski/jupyterlab-lsp/issues/421
2125

2226
### `@krassowski/jupyterlab_go_to_definition 2.0.0` (???)
2327

atest/03_Notebook.robot

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,39 @@ Python
1111
Capture Page Screenshot 01-python.png
1212
[Teardown] Clean Up After Working With File Python.ipynb
1313

14+
Conversion Of Cell Types
15+
[Setup] Setup Notebook Python Python.ipynb
16+
${lsp_entry} = Set Variable Show diagnostics panel
17+
# initial (code) cell
18+
Open Context Menu Over Cell Editor 1
19+
Capture Page Screenshot 01-initial-code-cell.png
20+
Context Menu Should Contain ${lsp_entry}
21+
Close Context Menu
22+
# raw cell
23+
Lab Command Change to Raw Cell Type
24+
Open Context Menu Over Cell Editor 1
25+
Capture Page Screenshot 02-as-raw-cell.png
26+
Context Menu Should Not Contain ${lsp_entry}
27+
Close Context Menu
28+
# code cell again
29+
Lab Command Change to Code Cell Type
30+
Open Context Menu Over Cell Editor 1
31+
Capture Page Screenshot 03-as-code-cell-again.png
32+
Context Menu Should Contain ${lsp_entry}
33+
Close Context Menu
34+
[Teardown] Clean Up After Working With File Python.ipynb
35+
36+
Moving Cells Around
37+
[Setup] Setup Notebook Python Python.ipynb
38+
${diagnostic} = Set Variable undefined name 'test' (pyflakes)
39+
Enter Cell Editor 1
40+
Lab Command Move Cells Down
41+
Wait Until Page Contains Element css:.cm-lsp-diagnostic[title="${diagnostic}"] timeout=35s
42+
Enter Cell Editor 1
43+
Lab Command Move Cells Down
44+
Wait Until Page Does Not Contain Element css:.cm-lsp-diagnostic[title="${diagnostic}"] timeout=35s
45+
[Teardown] Clean Up After Working With File Python.ipynb
46+
1447
Foreign Extractors
1548
${file} = Set Variable Foreign extractors.ipynb
1649
Configure JupyterLab Plugin

atest/Keywords.robot

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ Enter Cell Editor
279279
Click Element css:.jp-Cell:nth-child(${cell_nr}) .CodeMirror-line:nth-child(${line})
280280
Wait Until Page Contains Element css:.jp-Cell:nth-child(${cell_nr}) .CodeMirror-focused
281281

282+
Open Context Menu Over Cell Editor
283+
[Arguments] ${cell_nr} ${line}=1
284+
Enter Cell Editor ${cell_nr} line=${line}
285+
Open Context Menu Over css:.jp-Cell:nth-child(${cell_nr}) .CodeMirror-line:nth-child(${line})
286+
282287
Place Cursor In Cell Editor At
283288
[Arguments] ${cell_nr} ${line} ${character}
284289
Enter Cell Editor ${cell_nr} ${line}
@@ -301,6 +306,19 @@ Open Context Menu Over
301306
Wait Until Keyword Succeeds 10 x 0.1 s Mouse Over ${sel}
302307
Wait Until Keyword Succeeds 10 x 0.1 s Open Context Menu ${sel}
303308

309+
Context Menu Should Contain
310+
[Arguments] ${label} ${timeout}=10s
311+
${entry} Set Variable xpath://div[contains(@class, 'lm-Menu-itemLabel')][contains(text(), '${label}')]
312+
Wait Until Page Contains Element ${entry} timeout=${timeout}
313+
314+
Context Menu Should Not Contain
315+
[Arguments] ${label} ${timeout}=10s
316+
${entry} Set Variable xpath://div[contains(@class, 'lm-Menu-itemLabel')][contains(text(), '${label}')]
317+
Wait Until Page Does Not Contain Element ${entry} timeout=${timeout}
318+
319+
Close Context Menu
320+
Press Keys None ESCAPE
321+
304322
Prepare File for Editing
305323
[Arguments] ${Language} ${Screenshots} ${file}
306324
Set Tags language:${Language.lower()}

packages/jupyterlab-lsp/src/adapters/adapter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ export abstract class WidgetAdapter<T extends IDocumentWidget> {
9696
protected app: JupyterFrontEnd;
9797

9898
public activeEditorChanged: Signal<WidgetAdapter<T>, IEditorChangedData>;
99+
public editorAdded: Signal<WidgetAdapter<T>, IEditorChangedData>;
100+
public editorRemoved: Signal<WidgetAdapter<T>, IEditorChangedData>;
99101
public update_finished: Promise<void>;
100102

101103
/**
@@ -117,6 +119,8 @@ export abstract class WidgetAdapter<T extends IDocumentWidget> {
117119
this.connection_manager = extension.connection_manager;
118120
this.adapterConnected = new Signal(this);
119121
this.activeEditorChanged = new Signal(this);
122+
this.editorRemoved = new Signal(this);
123+
this.editorAdded = new Signal(this);
120124
this.adapters = new Map();
121125
this.status_message = new StatusMessage();
122126
this.isConnected = false;

packages/jupyterlab-lsp/src/adapters/notebook/notebook.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { WidgetAdapter } from '../adapter';
22
import { Notebook, NotebookPanel } from '@jupyterlab/notebook';
33
import { until_ready } from '../../utils';
4-
import { Cell } from '@jupyterlab/cells';
4+
import { Cell, ICellModel } from '@jupyterlab/cells';
55
import * as nbformat from '@jupyterlab/nbformat';
66
import ILanguageInfoMetadata = nbformat.ILanguageInfoMetadata;
77
import { Session } from '@jupyterlab/services';
@@ -17,13 +17,16 @@ import { VirtualDocument } from '../../virtual/document';
1717
export class NotebookAdapter extends WidgetAdapter<NotebookPanel> {
1818
editor: Notebook;
1919
private ce_editor_to_cell: Map<IEditor, Cell>;
20+
private known_editors_ids: Set<string>;
2021

2122
private _language_info: ILanguageInfoMetadata;
23+
private type: nbformat.CellType = 'code';
2224

2325
constructor(extension: LSPExtension, editor_widget: NotebookPanel) {
2426
super(extension, editor_widget);
2527
this.ce_editor_to_cell = new Map();
2628
this.editor = editor_widget.content;
29+
this.known_editors_ids = new Set();
2730
this.init_once_ready().catch(console.warn);
2831
}
2932

@@ -136,6 +139,83 @@ export class NotebookAdapter extends WidgetAdapter<NotebookPanel> {
136139
);
137140

138141
this.widget.content.activeCellChanged.connect(this.activeCellChanged, this);
142+
this.widget.model.cells.changed.connect(async (cells, change) => {
143+
let cellsAdded: ICellModel[] = [];
144+
let cellsRemoved: ICellModel[] = [];
145+
const type = this.type;
146+
147+
if (change.type === 'set') {
148+
// handling of conversions is important, because the editors get re-used and their handlers inherited,
149+
// so we need to clear our handlers from editors of e.g. markdown cells which previously were code cells.
150+
let convertedToMarkdownOrRaw = [];
151+
let convertedToCode = [];
152+
153+
if (change.newValues.length === change.oldValues.length) {
154+
// during conversion the cells should not get deleted nor added
155+
for (let i = 0; i < change.newValues.length; i++) {
156+
if (
157+
change.oldValues[i].type === type &&
158+
change.newValues[i].type !== type
159+
) {
160+
convertedToMarkdownOrRaw.push(change.newValues[i]);
161+
} else if (
162+
change.oldValues[i].type !== type &&
163+
change.newValues[i].type === type
164+
) {
165+
convertedToCode.push(change.newValues[i]);
166+
}
167+
}
168+
cellsAdded = convertedToCode;
169+
cellsRemoved = convertedToMarkdownOrRaw;
170+
}
171+
} else if (change.type == 'add') {
172+
cellsAdded = change.newValues.filter(
173+
cellModel => cellModel.type === type
174+
);
175+
}
176+
// note: editorRemoved is not emitted for removal of cells by change of type 'remove' (but only during cell type conversion)
177+
// because there is no easy way to get the widget associated with the removed cell(s) - because it is no
178+
// longer in the notebook widget list! It would need to be tracked on our side, but it is not necessary
179+
// as (except for a tiny memory leak) it should not impact the functionality in any way
180+
181+
if (
182+
cellsRemoved.length ||
183+
cellsAdded.length ||
184+
change.type === 'move' ||
185+
change.type === 'remove'
186+
) {
187+
// in contrast to the file editor document which can be only changed by the modification of the editor content,
188+
// the notebook document cna also get modified by a change in the number or arrangement of editors themselves;
189+
// for this reason each change has to trigger documents update (so that LSP mirror is in sync).
190+
await this.update_documents();
191+
}
192+
193+
for (let cellModel of cellsRemoved) {
194+
let cellWidget = this.widget.content.widgets.find(
195+
cell => cell.model.id === cellModel.id
196+
);
197+
this.known_editors_ids.delete(cellWidget.editor.uuid);
198+
199+
// for practical purposes this editor got removed from our consideration;
200+
// it might seem that we should instead look for the editor indicated by
201+
// the oldValues[i] cellModel, but this one got already transferred to the
202+
// markdown cell in newValues[i]
203+
this.editorRemoved.emit({
204+
editor: cellWidget.editor
205+
});
206+
}
207+
208+
for (let cellModel of cellsAdded) {
209+
let cellWidget = this.widget.content.widgets.find(
210+
cell => cell.model.id === cellModel.id
211+
);
212+
this.known_editors_ids.add(cellWidget.editor.uuid);
213+
214+
this.editorAdded.emit({
215+
editor: cellWidget.editor
216+
});
217+
}
218+
});
139219
}
140220

141221
get editors(): CodeEditor.IEditor[] {
@@ -178,13 +258,29 @@ export class NotebookAdapter extends WidgetAdapter<NotebookPanel> {
178258
}
179259

180260
private activeCellChanged(notebook: Notebook, cell: Cell) {
261+
if (cell.model.type !== this.type) {
262+
return;
263+
}
264+
if (!this.known_editors_ids.has(cell.editor.uuid)) {
265+
this.known_editors_ids.add(cell.editor.uuid);
266+
this.editorAdded.emit({
267+
editor: cell.editor
268+
});
269+
}
181270
this.activeEditorChanged.emit({
182271
editor: cell.editor
183272
});
184273
}
185274

186275
context_from_active_document(): ICommandContext | null {
187276
let cell = this.widget.content.activeCell;
277+
if (cell.model.type !== this.type) {
278+
// context will be sought on all cells to verify if the context menu should be visible,
279+
// thus it is ok to just return null; it seems to stem from the implementation detail
280+
// upstream, i.e. the markdown cells appear to be created by transforming the code cells
281+
// but do not quote me on that.
282+
return null;
283+
}
188284
let editor = cell.editor;
189285
let ce_cursor = editor.getCursorPosition();
190286
let cm_cursor = PositionConverter.ce_to_cm(ce_cursor) as IEditorPosition;

packages/jupyterlab-lsp/src/virtual/codemirror_editor.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,15 @@ export class CodeMirrorVirtualEditor
280280
};
281281
this._event_wrappers.set([eventName, handler], wrapped_handler);
282282

283-
this.forEveryBlockEditor(cm_editor => {
284-
cm_editor.on(eventName, wrapped_handler);
285-
});
283+
this.forEveryBlockEditor(
284+
cm_editor => {
285+
cm_editor.on(eventName, wrapped_handler);
286+
},
287+
true,
288+
cm_editor => {
289+
cm_editor.off(eventName, wrapped_handler);
290+
}
291+
);
286292
}
287293

288294
off(eventName: string, handler: CodeMirrorHandler, ...args: any[]): void {
@@ -496,28 +502,25 @@ export class CodeMirrorVirtualEditor
496502
return 0;
497503
}
498504

499-
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
500-
this.forEveryBlockEditor(cm_editor => {
501-
cm_editor.getWrapperElement().addEventListener(type, listener);
502-
});
503-
}
504-
505505
forEveryBlockEditor(
506506
callback: (cm_editor: CodeMirror.Editor) => any,
507-
monitor_for_new_blocks = true
507+
monitor_for_new_blocks = true,
508+
on_editor_removed_callback: (cm_editor: CodeMirror.Editor) => any = null
508509
) {
509510
const editors_with_handlers = new Set<CodeMirror.Editor>();
510511

511-
// TODO... the need of iterating over all editors is universal. How does the virtual
512-
// editor gets knowledge of the editor instances? From the adapter obviously.
512+
// TODO... the need of iterating over all editors is universal - so this could be
513+
// generalised to the VirtualEditor rather than live in CodeMirrorVirtualEditor;
514+
// How would the VirtualEditor get knowledge of the editor instances?
515+
// From the adapter (obviously).
513516
for (let editor of this.adapter.editors) {
514517
let cm_editor = (editor as CodeMirrorEditor).editor;
515518
editors_with_handlers.add(cm_editor);
516519
callback(cm_editor);
517520
}
518521
if (monitor_for_new_blocks) {
519-
this.adapter.activeEditorChanged.connect(
520-
(adapter, data: IEditorChangedData) => {
522+
this.adapter.editorAdded.connect(
523+
(adapter: WidgetAdapter<IDocumentWidget>, data: IEditorChangedData) => {
521524
let { editor } = data;
522525
if (editor == null) {
523526
return;
@@ -528,6 +531,16 @@ export class CodeMirrorVirtualEditor
528531
}
529532
}
530533
);
534+
this.adapter.editorRemoved.connect(
535+
(adapter, data: IEditorChangedData) => {
536+
let { editor } = data;
537+
if (editor == null) {
538+
return;
539+
}
540+
let cm_editor = (editor as CodeMirrorEditor).editor;
541+
on_editor_removed_callback(cm_editor);
542+
}
543+
);
531544
}
532545
}
533546

0 commit comments

Comments
 (0)