Skip to content

Commit 0d7d3ca

Browse files
authored
Minimize the changes to ipynb files when saving (microsoft#7982)
* Minimize the changes to ipynb files when saving * Use common location for source splitting * Make sure to throw json error before setting any state * Fix functional and unit tests * Fix linter problem
1 parent e807b04 commit 0d7d3ca

31 files changed

+336
-143
lines changed

news/2 Fixes/7960.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Minimize the changes to an ipynb file when saving - preserve metadata and spacing.

package-lock.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2671,12 +2671,13 @@
26712671
"verifyBundle": "gulp verifyBundle"
26722672
},
26732673
"dependencies": {
2674+
"@blueprintjs/select": "3.10.0",
26742675
"@jupyterlab/services": "^3.2.1",
26752676
"@msrvida/python-program-analysis": "^0.2.6",
2676-
"@blueprintjs/select": "3.10.0",
26772677
"ansi-regex": "^4.1.0",
26782678
"arch": "^2.1.0",
26792679
"azure-storage": "^2.10.3",
2680+
"detect-indent": "^6.0.0",
26802681
"diff-match-patch": "^1.0.0",
26812682
"fast-deep-equal": "^2.0.1",
26822683
"fs-extra": "^4.0.3",

src/client/datascience/cellFactory.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ function generateCodeCell(code: string[], file: string, line: number, id: string
4343
id: id,
4444
file: file,
4545
line: line,
46-
state: CellState.init,
47-
type: 'execute'
46+
state: CellState.init
4847
};
4948

5049
}
@@ -55,7 +54,6 @@ function generateMarkdownCell(code: string[], file: string, line: number, id: st
5554
file: file,
5655
line: line,
5756
state: CellState.finished,
58-
type: 'execute',
5957
data: {
6058
cell_type: 'markdown',
6159
source: generateMarkdownFromCodeLines(code),

src/client/datascience/common.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { noop } from '../../test/core';
77

88
const SingleQuoteMultiline = '\'\'\'';
99
const DoubleQuoteMultiline = '\"\"\"';
10-
export function concatMultilineString(str: nbformat.MultilineString): string {
10+
11+
function concatMultilineString(str: nbformat.MultilineString, trim: boolean): string {
1112
const nonLineFeedWhiteSpaceTrim = /(^[\t\f\v\r ]+|[\t\f\v\r ]+$)/g; // Local var so don't have to reset the lastIndex.
1213
if (Array.isArray(str)) {
1314
let result = '';
@@ -21,16 +22,35 @@ export function concatMultilineString(str: nbformat.MultilineString): string {
2122
}
2223

2324
// Just trim whitespace. Leave \n in place
24-
return result.replace(nonLineFeedWhiteSpaceTrim, '');
25+
return trim ? result.replace(nonLineFeedWhiteSpaceTrim, '') : result;
2526
}
26-
return str.toString().replace(nonLineFeedWhiteSpaceTrim, '');
27+
return trim ? str.toString().replace(nonLineFeedWhiteSpaceTrim, '') : str.toString();
2728
}
2829

29-
export function splitMultilineString(str: nbformat.MultilineString): string[] {
30-
if (Array.isArray(str)) {
31-
return str as string[];
30+
export function concatMultilineStringOutput(str: nbformat.MultilineString): string {
31+
return concatMultilineString(str, true);
32+
}
33+
export function concatMultilineStringInput(str: nbformat.MultilineString): string {
34+
return concatMultilineString(str, false);
35+
}
36+
37+
export function splitMultilineString(source: nbformat.MultilineString): string[] {
38+
// Make sure a multiline string is back the way Jupyter expects it
39+
if (Array.isArray(source)) {
40+
return source as string[];
41+
}
42+
const str = source.toString();
43+
if (str.length > 0) {
44+
// Each line should be a separate entry, but end with a \n if not last entry
45+
const arr = str.split('\n');
46+
return arr.map((s, i) => {
47+
if (i < arr.length - 1) {
48+
return `${s}\n`;
49+
}
50+
return s;
51+
}).filter(s => s.length > 0); // Skip last one if empty (it's the only one that could be length 0)
3252
}
33-
return str.toString().split('\n');
53+
return [];
3454
}
3555

3656
// Strip out comment lines from code

src/client/datascience/gather/gather.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import * as localize from '../../common/utils/localize';
1414
import { Common } from '../../common/utils/localize';
1515
import { noop } from '../../common/utils/misc';
1616
import { CellMatcher } from '../cellMatcher';
17-
import { concatMultilineString } from '../common';
17+
import { concatMultilineStringInput } from '../common';
1818
import { Identifiers } from '../constants';
1919
import { CellState, ICell as IVscCell, IGatherExecution, INotebookExecutionLogger } from '../types';
2020

@@ -60,7 +60,7 @@ export class GatherExecution implements IGatherExecution, INotebookExecutionLogg
6060

6161
// Strip first line marker. We can't do this at JupyterServer.executeCodeObservable because it messes up hashing
6262
const cellMatcher = new CellMatcher(this.configService.getSettings().datascience);
63-
cloneCell.data.source = cellMatcher.stripFirstMarker(concatMultilineString(vscCell.data.source));
63+
cloneCell.data.source = cellMatcher.stripFirstMarker(concatMultilineStringInput(vscCell.data.source));
6464

6565
// Convert IVscCell to IGatherCell
6666
const cell = convertVscToGatherCell(cloneCell) as LogCell;

src/client/datascience/interactive-common/intellisense/baseIntellisenseProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { CancellationError } from '../../../common/cancellation';
2222
import { traceWarning } from '../../../common/logger';
2323
import { IFileSystem, TemporaryFile } from '../../../common/platform/types';
2424
import { createDeferred, Deferred, waitForPromise } from '../../../common/utils/async';
25-
import { concatMultilineString } from '../../common';
25+
import { concatMultilineStringInput } from '../../common';
2626
import { Identifiers, Settings } from '../../constants';
2727
import { IInteractiveWindowListener, IInteractiveWindowProvider, IJupyterExecution, INotebook } from '../../types';
2828
import {
@@ -387,7 +387,7 @@ export abstract class BaseIntellisenseProvider implements IInteractiveWindowList
387387
if (document) {
388388
const changes = document.loadAllCells(payload.cells.filter(c => c.data.cell_type === 'code').map(cell => {
389389
return {
390-
code: concatMultilineString(cell.data.source),
390+
code: concatMultilineStringInput(cell.data.source),
391391
id: cell.id
392392
};
393393
}));

src/client/datascience/interactive-common/interactiveBase.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,13 +557,12 @@ export abstract class InteractiveBase extends WebViewHost<IInteractiveWindowMapp
557557
return result;
558558
}
559559

560-
protected addMessageImpl(message: string, type: 'preview' | 'execute'): void {
560+
protected addMessageImpl(message: string): void {
561561
const cell: ICell = {
562562
id: uuid(),
563563
file: Identifiers.EmptyFileName,
564564
line: 0,
565565
state: CellState.finished,
566-
type,
567566
data: {
568567
cell_type: 'messages',
569568
messages: [message],

src/client/datascience/interactive-ipynb/nativeEditor.ts

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,22 @@
33
'use strict';
44
import '../../common/extensions';
55

6+
import { nbformat } from '@jupyterlab/coreutils/lib/nbformat';
7+
import * as detectIndent from 'detect-indent';
68
import * as fastDeepEqual from 'fast-deep-equal';
79
import { inject, injectable, multiInject, named } from 'inversify';
810
import * as path from 'path';
911
import * as uuid from 'uuid/v4';
1012
import { Event, EventEmitter, Memento, Uri, ViewColumn } from 'vscode';
1113

12-
import { IApplicationShell, ICommandManager, IDocumentManager, ILiveShareApi, IWebPanelProvider, IWorkspaceService } from '../../common/application/types';
14+
import {
15+
IApplicationShell,
16+
ICommandManager,
17+
IDocumentManager,
18+
ILiveShareApi,
19+
IWebPanelProvider,
20+
IWorkspaceService
21+
} from '../../common/application/types';
1322
import { ContextKey } from '../../common/contextKey';
1423
import { traceError } from '../../common/logger';
1524
import { IFileSystem, TemporaryFile } from '../../common/platform/types';
@@ -20,10 +29,23 @@ import { StopWatch } from '../../common/utils/stopWatch';
2029
import { EXTENSION_ROOT_DIR } from '../../constants';
2130
import { IInterpreterService } from '../../interpreter/contracts';
2231
import { captureTelemetry, sendTelemetryEvent } from '../../telemetry';
23-
import { concatMultilineString } from '../common';
24-
import { EditorContexts, Identifiers, NativeKeyboardCommandTelemetryLookup, NativeMouseCommandTelemetryLookup, Telemetry } from '../constants';
32+
import { concatMultilineStringInput, splitMultilineString } from '../common';
33+
import {
34+
EditorContexts,
35+
Identifiers,
36+
NativeKeyboardCommandTelemetryLookup,
37+
NativeMouseCommandTelemetryLookup,
38+
Telemetry
39+
} from '../constants';
2540
import { InteractiveBase } from '../interactive-common/interactiveBase';
26-
import { IEditCell, INativeCommand, InteractiveWindowMessages, ISaveAll, ISubmitNewCell } from '../interactive-common/interactiveWindowTypes';
41+
import {
42+
IEditCell,
43+
INativeCommand,
44+
InteractiveWindowMessages,
45+
ISaveAll,
46+
ISubmitNewCell
47+
} from '../interactive-common/interactiveWindowTypes';
48+
import { InvalidNotebookFileError } from '../jupyter/invalidNotebookFileError';
2749
import {
2850
CellState,
2951
ICell,
@@ -62,6 +84,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
6284
private visibleCells: ICell[] = [];
6385
private startupTimer: StopWatch = new StopWatch();
6486
private loadedAllCells: boolean = false;
87+
private indentAmount: string = ' ';
88+
private notebookJson: Partial<nbformat.INotebookContent> = {};
6589

6690
constructor(
6791
@multiInject(IInteractiveWindowListener) listeners: IInteractiveWindowListener[],
@@ -132,7 +156,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
132156
this.close().ignoreErrors();
133157
}
134158

135-
public async load(content: string, file: Uri): Promise<void> {
159+
public async load(contents: string, file: Uri): Promise<void> {
136160
// Save our uri
137161
this._file = file;
138162

@@ -149,12 +173,10 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
149173
const dirtyContents = this.getStoredContents();
150174
if (dirtyContents) {
151175
// This means we're dirty. Indicate dirty and load from this content
152-
const cells = await this.importer.importCells(dirtyContents);
153-
return this.loadCells(cells, true);
176+
return this.loadContents(dirtyContents, true);
154177
} else {
155-
// Load the contents of this notebook into our cells.
156-
const cells = content ? await this.importer.importCells(content) : [];
157-
return this.loadCells(cells, false);
178+
// Load without setting dirty
179+
return this.loadContents(contents, false);
158180
}
159181
}
160182

@@ -318,8 +340,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
318340
id: info.id,
319341
file: Identifiers.EmptyFileName,
320342
line: 0,
321-
state: CellState.error,
322-
type: 'execute'
343+
state: CellState.error
323344
}
324345
]);
325346

@@ -391,6 +412,41 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
391412
// Actually don't close, just let the error bubble out
392413
}
393414

415+
private async loadContents(contents: string | undefined, forceDirty: boolean): Promise<void> {
416+
// tslint:disable-next-line: no-any
417+
const json = contents ? JSON.parse(contents) as any : undefined;
418+
419+
// Double check json (if we have any)
420+
if (json && !json.cells) {
421+
throw new InvalidNotebookFileError(this.file.fsPath);
422+
}
423+
424+
// Then compute indent. It's computed from the contents
425+
if (contents) {
426+
this.indentAmount = detectIndent(contents).indent;
427+
}
428+
429+
// Then save the contents. We'll stick our cells back into this format when we save
430+
if (json) {
431+
this.notebookJson = json;
432+
}
433+
434+
// Extract cells from the json
435+
const cells = contents ? json.cells as (nbformat.ICodeCell | nbformat.IRawCell | nbformat.IMarkdownCell)[] : [];
436+
437+
// Then parse the cells
438+
return this.loadCells(cells.map((c, index) => {
439+
return {
440+
id: `NotebookImport#${index}`,
441+
file: Identifiers.EmptyFileName,
442+
line: 0,
443+
state: CellState.finished,
444+
data: c
445+
};
446+
}), forceDirty);
447+
448+
}
449+
394450
private async loadCells(cells: ICell[], forceDirty: boolean): Promise<void> {
395451
// Make sure cells have at least 1
396452
if (cells.length === 0) {
@@ -399,7 +455,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
399455
line: 0,
400456
file: Identifiers.EmptyFileName,
401457
state: CellState.finished,
402-
type: 'execute',
403458
data: {
404459
cell_type: 'code',
405460
outputs: [],
@@ -419,7 +474,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
419474
if (forceDirty) {
420475
await this.setDirty();
421476
}
422-
sendTelemetryEvent(Telemetry.CellCount, undefined, {count: cells.length});
477+
sendTelemetryEvent(Telemetry.CellCount, undefined, { count: cells.length });
423478
return this.postMessage(InteractiveWindowMessages.LoadAllCells, { cells });
424479
}
425480

@@ -497,7 +552,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
497552
const cell = this.visibleCells.find(c => c.id === request.id);
498553
if (cell) {
499554
// This is an actual edit.
500-
const contents = concatMultilineString(cell.data.source);
555+
const contents = concatMultilineStringInput(cell.data.source);
501556
const before = contents.substr(0, change.rangeOffset);
502557
const after = contents.substr(change.rangeOffset + change.rangeLength);
503558
const newContents = `${before}${normalized}${after}`;
@@ -533,8 +588,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
533588
this.visibleCells = cells;
534589

535590
// Save our dirty state in the storage for reopen later
536-
const notebook = await this.jupyterExporter.translateToNotebook(this.visibleCells, undefined);
537-
await this.storeContents(JSON.stringify(notebook));
591+
await this.storeContents(this.generateNotebookContent(cells));
538592

539593
// Indicate dirty
540594
await this.setDirty();
@@ -569,10 +623,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
569623
tempFile = await this.fileSystem.createTemporaryFile('.ipynb');
570624

571625
// Translate the cells into a notebook
572-
const notebook = await this.jupyterExporter.translateToNotebook(cells, undefined);
573-
574-
// Write the cells to this file
575-
await this.fileSystem.writeFile(tempFile.filePath, JSON.stringify(notebook), { encoding: 'utf-8' });
626+
await this.fileSystem.writeFile(tempFile.filePath, this.generateNotebookContent(cells), { encoding: 'utf-8' });
576627

577628
// Import this file and show it
578629
const contents = await this.importer.importFromFile(tempFile.filePath);
@@ -594,6 +645,23 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
594645
await this.documentManager.showTextDocument(doc, ViewColumn.One);
595646
}
596647

648+
private fixupCell(cell: nbformat.ICell): nbformat.ICell {
649+
// Source is usually a single string on input. Convert back to an array
650+
return {
651+
...cell,
652+
source: splitMultilineString(cell.source)
653+
};
654+
}
655+
656+
private generateNotebookContent(cells: ICell[]): string {
657+
// Reuse our original json except for the cells.
658+
const json = {
659+
...this.notebookJson,
660+
cells: cells.map(c => this.fixupCell(c.data))
661+
};
662+
return JSON.stringify(json, null, this.indentAmount);
663+
}
664+
597665
@captureTelemetry(Telemetry.Save, undefined, true)
598666
private async saveToDisk(): Promise<void> {
599667
try {
@@ -617,9 +685,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
617685
}
618686

619687
if (fileToSaveTo && isDirty) {
620-
// Save our visible cells into the file
621-
const notebook = await this.jupyterExporter.translateToNotebook(this.visibleCells, undefined);
622-
await this.fileSystem.writeFile(fileToSaveTo.fsPath, JSON.stringify(notebook));
688+
// Write out our visible cells
689+
await this.fileSystem.writeFile(fileToSaveTo.fsPath, this.generateNotebookContent(this.visibleCells));
623690

624691
// Update our file name and dirty state
625692
this._file = fileToSaveTo;

src/client/datascience/interactive-window/interactiveWindow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export class InteractiveWindow extends InteractiveBase implements IInteractiveWi
118118
}
119119

120120
public addMessage(message: string): Promise<void> {
121-
this.addMessageImpl(message, 'execute');
121+
this.addMessageImpl(message);
122122
return Promise.resolve();
123123
}
124124

0 commit comments

Comments
 (0)