Skip to content

Commit ae6abc0

Browse files
authored
Support moving/adding/deleting cells in notebook intellisense (#15208)
* Preliminary idea * Account for range end position on deletes * Fix moves * Test passing * Simplify movement * Make sure testing * Potentially handle multiple deletes or insertions * Review feedback
1 parent 7780dcc commit ae6abc0

5 files changed

Lines changed: 238 additions & 3 deletions

File tree

src/client/jupyter/languageserver/notebookConcatDocument.ts

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,21 @@
22
// Licensed under the MIT License.
33
import * as path from 'path';
44
import * as uuid from 'uuid/v4';
5-
import { Disposable, DocumentSelector, EndOfLine, Position, Range, TextDocument, TextLine, Uri } from 'vscode';
5+
import {
6+
Disposable,
7+
DocumentSelector,
8+
EndOfLine,
9+
Event,
10+
EventEmitter,
11+
Position,
12+
Location,
13+
Range,
14+
TextDocument,
15+
TextDocumentChangeEvent,
16+
TextLine,
17+
Uri,
18+
} from 'vscode';
19+
import { isEqual } from 'lodash';
620
import { NotebookConcatTextDocument, NotebookCell, NotebookDocument } from 'vscode-proposed';
721
import { IVSCodeNotebook } from '../../common/application/types';
822
import { IDisposable } from '../../common/types';
@@ -54,6 +68,10 @@ export class NotebookConcatDocument implements TextDocument, IDisposable {
5468
return this.notebook.cells.map((c) => c.document.lineCount).reduce((p, c) => p + c);
5569
}
5670

71+
public get onCellsChanged(): Event<TextDocumentChangeEvent> {
72+
return this.onCellsChangedEmitter.event;
73+
}
74+
5775
public firedOpen = false;
5876

5977
public firedClose = false;
@@ -68,6 +86,10 @@ export class NotebookConcatDocument implements TextDocument, IDisposable {
6886

6987
private onDidChangeSubscription: Disposable;
7088

89+
private cellTracking: { uri: Uri; lineCount: number; length: number }[] = [];
90+
91+
private onCellsChangedEmitter = new EventEmitter<TextDocumentChangeEvent>();
92+
7193
constructor(public notebook: NotebookDocument, notebookApi: IVSCodeNotebook, selector: DocumentSelector) {
7294
const dir = path.dirname(notebook.uri.fsPath);
7395
// Note: Has to be different than the prefix for old notebook editor (HiddenFileFormat) so
@@ -76,6 +98,7 @@ export class NotebookConcatDocument implements TextDocument, IDisposable {
7698
this.dummyUri = Uri.file(this.dummyFilePath);
7799
this.concatDocument = notebookApi.createConcatTextDocument(notebook, selector);
78100
this.onDidChangeSubscription = this.concatDocument.onDidChange(this.onDidChange, this);
101+
this.updateCellTracking();
79102
}
80103

81104
public dispose(): void {
@@ -139,7 +162,133 @@ export class NotebookConcatDocument implements TextDocument, IDisposable {
139162
return this.notebook.cells.find((c) => c.uri === location.uri);
140163
}
141164

165+
private updateCellTracking() {
166+
this.cellTracking = [];
167+
this.notebook.cells.forEach((c) => {
168+
// Compute end position from number of lines in a cell
169+
const cellText = c.document.getText();
170+
const lines = cellText.splitLines({ trim: false });
171+
172+
this.cellTracking.push({
173+
uri: c.uri,
174+
length: cellText.length + 1, // \n is included concat length
175+
lineCount: lines.length,
176+
});
177+
});
178+
}
179+
142180
private onDidChange() {
143181
this._version += 1;
182+
const newUris = this.notebook.cells.map((c) => c.uri.toString());
183+
const oldUris = this.cellTracking.map((c) => c.uri.toString());
184+
185+
// See if number of cells or cell positions changed
186+
if (this.cellTracking.length < this.notebook.cells.length) {
187+
this.raiseCellInsertions(oldUris);
188+
} else if (this.cellTracking.length > this.notebook.cells.length) {
189+
this.raiseCellDeletions(newUris, oldUris);
190+
} else if (!isEqual(oldUris, newUris)) {
191+
this.raiseCellMovement();
192+
}
193+
this.updateCellTracking();
194+
}
195+
196+
private getPositionOfCell(cellUri: Uri): Position {
197+
return this.concatDocument.positionAt(new Location(cellUri, new Position(0, 0)));
198+
}
199+
200+
public getEndPosition(): Position {
201+
if (this.notebook.cells.length > 0) {
202+
const finalCell = this.notebook.cells[this.notebook.cells.length - 1];
203+
const start = this.getPositionOfCell(finalCell.uri);
204+
const lines = finalCell.document.getText().splitLines({ trim: false });
205+
return new Position(start.line + lines.length, 0);
206+
}
207+
return new Position(0, 0);
208+
}
209+
210+
private raiseCellInsertions(oldUris: string[]) {
211+
// One or more cells were added. Add a change event for each
212+
const insertions = this.notebook.cells.filter((c) => !oldUris.includes(c.uri.toString()));
213+
214+
const changes = insertions.map((insertion) => {
215+
// Figure out the position of the item. This is where we're inserting the cell
216+
// Note: The first insertion will line up with the old cell at this position
217+
// The second or other insertions will line up with their new positions.
218+
const position = this.getPositionOfCell(insertion.uri);
219+
220+
// Text should be the contents of the new cell plus the '\n'
221+
const text = `${insertion.document.getText()}\n`;
222+
223+
return {
224+
text,
225+
range: new Range(position, position),
226+
rangeLength: 0,
227+
rangeOffset: 0,
228+
};
229+
});
230+
231+
// Send all of the changes
232+
this.onCellsChangedEmitter.fire({
233+
document: this,
234+
contentChanges: changes,
235+
});
236+
}
237+
238+
private raiseCellDeletions(newUris: string[], oldUris: string[]) {
239+
// cells were deleted. Figure out which ones
240+
const oldIndexes: number[] = [];
241+
oldUris.forEach((o, i) => {
242+
if (!newUris.includes(o)) {
243+
oldIndexes.push(i);
244+
}
245+
});
246+
const changes = oldIndexes.map((index) => {
247+
// Figure out the position of the item in the new list
248+
const position =
249+
index < newUris.length ? this.getPositionOfCell(this.notebook.cells[index].uri) : this.getEndPosition();
250+
251+
// Length should be old length
252+
const { length } = this.cellTracking[index];
253+
254+
// Range should go from new position to end of old position
255+
const endPosition = new Position(position.line + this.cellTracking[index].lineCount, 0);
256+
257+
// Turn this cell into a change event.
258+
return {
259+
text: '',
260+
range: new Range(position, endPosition),
261+
rangeLength: length,
262+
rangeOffset: 0,
263+
};
264+
});
265+
266+
// Send the event
267+
this.onCellsChangedEmitter.fire({
268+
document: this,
269+
contentChanges: changes,
270+
});
271+
}
272+
273+
private raiseCellMovement() {
274+
// When moving, just replace everything. Simpler this way. Might this
275+
// cause unknown side effects? Don't think so.
276+
this.onCellsChangedEmitter.fire({
277+
document: this,
278+
contentChanges: [
279+
{
280+
text: this.concatDocument.getText(),
281+
range: new Range(
282+
new Position(0, 0),
283+
new Position(
284+
this.cellTracking.reduce((p, c) => p + c.lineCount, 0),
285+
0,
286+
),
287+
),
288+
rangeLength: this.cellTracking.reduce((p, c) => p + c.length, 0),
289+
rangeOffset: 0,
290+
},
291+
],
292+
});
144293
}
145294
}

src/client/jupyter/languageserver/notebookConverter.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
DocumentLink,
1616
DocumentSelector,
1717
DocumentSymbol,
18+
Event,
19+
EventEmitter,
1820
Hover,
1921
Location,
2022
LocationLink,
@@ -44,12 +46,18 @@ function toPosition(positionLike: Position): Position {
4446
*/
4547

4648
export class NotebookConverter implements Disposable {
49+
public get onDidChangeCells(): Event<TextDocumentChangeEvent> {
50+
return this.onDidChangeCellsEmitter.event;
51+
}
52+
4753
private activeDocuments: Map<string, NotebookConcatDocument> = new Map<string, NotebookConcatDocument>();
4854

4955
private activeDocumentsOutgoingMap: Map<string, NotebookConcatDocument> = new Map<string, NotebookConcatDocument>();
5056

5157
private disposables: Disposable[] = [];
5258

59+
private onDidChangeCellsEmitter = new EventEmitter<TextDocumentChangeEvent>();
60+
5361
constructor(
5462
private api: IVSCodeNotebook,
5563
private fs: IFileSystem,
@@ -627,6 +635,7 @@ export class NotebookConverter implements Disposable {
627635
throw new Error(`Invalid uri, not a notebook: ${uri.fsPath}`);
628636
}
629637
result = new NotebookConcatDocument(doc, this.api, this.cellSelector);
638+
result.onCellsChanged((e) => this.onDidChangeCellsEmitter.fire(e), undefined, this.disposables);
630639
this.activeDocuments.set(key, result);
631640
this.activeDocumentsOutgoingMap.set(NotebookConverter.getDocumentKey(result.uri), result);
632641
}

src/client/jupyter/languageserver/notebookMiddlewareAddon.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ import { NotebookConverter } from './notebookConverter';
7676
export class NotebookMiddlewareAddon implements Middleware, Disposable {
7777
private converter: NotebookConverter;
7878

79+
private didChangeCellsDisposable: Disposable;
80+
7981
constructor(
8082
notebookApi: IVSCodeNotebook,
8183
private readonly getClient: () => LanguageClient | undefined,
@@ -84,9 +86,11 @@ export class NotebookMiddlewareAddon implements Middleware, Disposable {
8486
notebookFileRegex: RegExp,
8587
) {
8688
this.converter = new NotebookConverter(notebookApi, fs, cellSelector, notebookFileRegex);
89+
this.didChangeCellsDisposable = this.converter.onDidChangeCells(this.onDidChangeCells.bind(this));
8790
}
8891

8992
public dispose(): void {
93+
this.didChangeCellsDisposable.dispose();
9094
this.converter.dispose();
9195
}
9296

@@ -481,4 +485,16 @@ export class NotebookMiddlewareAddon implements Middleware, Disposable {
481485
const newDiagMapping = this.converter.toIncomingDiagnosticsMap(uri, diagnostics);
482486
[...newDiagMapping.keys()].forEach((k) => next(k, newDiagMapping.get(k)!));
483487
}
488+
489+
private onDidChangeCells(e: TextDocumentChangeEvent) {
490+
// This event fires when the user moves, deletes, or inserts cells into the concatenated document
491+
// Since this doesn't fire a change event (since a document wasn't changed), we have to make one ourselves.
492+
493+
// Note: The event should already be setup to be an outgoing event. It's from the point of view of the concatenated document.
494+
const client = this.getClient();
495+
if (client) {
496+
const params = client.code2ProtocolConverter.asChangeTextDocumentParams(e);
497+
client.sendNotification(DidChangeTextDocumentNotification.type, params);
498+
}
499+
}
484500
}

src/test/initialize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async function closeWindowsInteral() {
8686
// Lets not waste too much time.
8787
const timer = setTimeout(() => {
8888
reject(new Error("Command 'workbench.action.closeAllEditors' timed out"));
89-
}, 15000);
89+
}, 2000);
9090
vscode.commands.executeCommand('workbench.action.closeAllEditors').then(
9191
() => {
9292
clearTimeout(timer);

src/test/insiders/languageServer.insiders.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as assert from 'assert';
77
import { expect } from 'chai';
88
import * as path from 'path';
99
import * as vscode from 'vscode';
10+
import { PYTHON_LANGUAGE } from '../../client/common/constants';
1011
import { updateSetting } from '../common';
1112
import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../constants';
1213
import { sleep } from '../core';
@@ -94,15 +95,75 @@ suite('Insiders Test: Language Server', () => {
9495
);
9596
if (locations && locations.length > 0) {
9697
expect(locations![0].uri.fsPath).to.contain(path.basename(notebookDefinitions));
98+
99+
// Insert a new cell
100+
const activeEditor = vscode.notebook.activeNotebookEditor;
101+
expect(activeEditor).not.to.be.equal(undefined, 'Active editor not found in notebook');
102+
await activeEditor!.edit((edit) => {
103+
edit.replaceCells(0, 0, [
104+
{
105+
cellKind: vscode.CellKind.Code,
106+
language: PYTHON_LANGUAGE,
107+
source: 'x = 4',
108+
metadata: {
109+
hasExecutionOrder: false,
110+
},
111+
outputs: [],
112+
},
113+
]);
114+
});
115+
116+
// Wait a bit to get diagnostics
117+
await sleep(1_000);
118+
119+
// Make sure no error diagnostics
120+
let diagnostics = vscode.languages.getDiagnostics(activeEditor!.document.uri);
121+
expect(diagnostics).to.have.lengthOf(0, 'Diagnostics found when shouldnt be');
122+
123+
// Move the cell
124+
await activeEditor!.edit((edit) => {
125+
edit.replaceCells(0, 1, []);
126+
edit.replaceCells(1, 0, [
127+
{
128+
cellKind: vscode.CellKind.Code,
129+
language: PYTHON_LANGUAGE,
130+
source: 'x = 4',
131+
metadata: {
132+
hasExecutionOrder: false,
133+
},
134+
outputs: [],
135+
},
136+
]);
137+
});
138+
139+
// Wait a bit to get diagnostics
140+
await sleep(1_000);
141+
142+
// Make sure no error diagnostics
143+
diagnostics = vscode.languages.getDiagnostics(activeEditor!.document.uri);
144+
expect(diagnostics).to.have.lengthOf(0, 'Diagnostics found when shouldnt be after move');
145+
146+
// Delete the cell
147+
await activeEditor!.edit((edit) => {
148+
edit.replaceCells(1, 1, []);
149+
});
150+
151+
// Wait a bit to get diagnostics
152+
await sleep(1_000);
153+
154+
// Make sure no error diagnostics
155+
diagnostics = vscode.languages.getDiagnostics(activeEditor!.document.uri);
156+
expect(diagnostics).to.have.lengthOf(0, 'Diagnostics found when shouldnt be after delete');
97157
tested = true;
158+
98159
break;
99160
} else {
100161
// Wait for LS to start.
101162
await sleep(5_000);
102163
}
103164
}
104165
if (!tested) {
105-
assert.fail('Failled to test definitions');
166+
assert.fail('Failled to test notebooks');
106167
}
107168
});
108169
});

0 commit comments

Comments
 (0)