Skip to content

Commit 1fcb309

Browse files
authored
Handling Save as of notebooks when using new VSC Notebook API (#11876)
For #10496 * Partial implementation of `Save As`. * There's more to this, but wanted to get in what I had. Final implementation details outlined here: * Based on discussions with team, https://github.com/microsoft/vscode-python/issues/11877 * We will need to wait for some VSCode API to be made available before we can complete this.
1 parent 3300b83 commit 1fcb309

File tree

11 files changed

+154
-39
lines changed

11 files changed

+154
-39
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,10 @@ export interface INotebookModelChange {
345345
export interface INotebookModelSaved extends INotebookModelChange {
346346
kind: 'save';
347347
}
348+
export interface INotebookModelSavedAs extends INotebookModelChange {
349+
kind: 'saveAs';
350+
target: Uri;
351+
}
348352

349353
export interface INotebookModelRemoveAllChange extends INotebookModelChange {
350354
kind: 'remove_all';
@@ -461,6 +465,7 @@ export interface INotebookModelVersionChange extends INotebookModelChange {
461465

462466
export type NotebookModelChange =
463467
| INotebookModelSaved
468+
| INotebookModelSavedAs
464469
| INotebookModelModifyChange
465470
| INotebookModelRemoveAllChange
466471
| INotebookModelClearChange

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { IFileSystem } from '../../common/platform/types';
1010
import { IDisposableRegistry, Resource } from '../../common/types';
1111
import { noop } from '../../common/utils/misc';
1212
import { Identifiers } from '../constants';
13+
import { INotebookStorageProvider } from '../interactive-ipynb/notebookStorageProvider';
1314
import {
1415
ConnectNotebookProviderOptions,
1516
GetNotebookOptions,
@@ -37,9 +38,11 @@ export class NotebookProvider implements INotebookProvider {
3738
@inject(IDisposableRegistry) disposables: IDisposableRegistry,
3839
@inject(IRawNotebookProvider) private readonly rawNotebookProvider: IRawNotebookProvider,
3940
@inject(IJupyterNotebookProvider) private readonly jupyterNotebookProvider: IJupyterNotebookProvider,
40-
@inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService
41+
@inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService,
42+
@inject(INotebookStorageProvider) storageProvider: INotebookStorageProvider
4143
) {
4244
disposables.push(editorProvider.onDidCloseNotebookEditor(this.onDidCloseNotebookEditor, this));
45+
disposables.push(storageProvider.onSavedAs(this.onSavedAs, this));
4346
disposables.push(
4447
interactiveWindowProvider.onDidChangeActiveInteractiveWindow(this.checkAndDisposeNotebook, this)
4548
);
@@ -156,6 +159,15 @@ export class NotebookProvider implements INotebookProvider {
156159
}
157160
}
158161

162+
private async onSavedAs(e: { new: Uri; old: Uri }) {
163+
// Swap the Uris when a notebook is saved as a different file.
164+
const notebookPromise = this.notebooks.get(e.old.toString());
165+
if (notebookPromise) {
166+
this.notebooks.set(e.new.toString(), notebookPromise);
167+
this.notebooks.delete(e.old.toString());
168+
}
169+
}
170+
159171
/**
160172
* Interactive windows have just one window.
161173
* When that it closed, just close all of the notebooks associated with interactive windows.

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { captureTelemetry, sendTelemetryEvent } from '../../telemetry';
2929
import { Telemetry } from '../constants';
3030
import { NotebookModelChange } from '../interactive-common/interactiveWindowTypes';
3131
import { INotebookEditor, INotebookEditorProvider, INotebookModel } from '../types';
32+
import { isUntitled } from './nativeEditorStorage';
3233
import { INotebookStorageProvider } from './notebookStorageProvider';
3334

3435
// Class that is registered as the custom editor provider for notebooks. VS code will call into this class when
@@ -265,10 +266,8 @@ export class NativeEditorProvider
265266
}
266267

267268
private async getNextNewNotebookUri(): Promise<Uri> {
268-
// tslint:disable-next-line: no-suspicious-comment
269-
// TODO: This will not work, if we close an untitled document.
270269
// See if we have any untitled storage already
271-
const untitledStorage = Array.from(this.models.values()).filter((model) => model?.file?.scheme === 'untitled');
270+
const untitledStorage = Array.from(this.models.values()).filter((model) => model && isUntitled(model));
272271
// Just use the length (don't bother trying to fill in holes). We never remove storage objects from
273272
// our map, so we'll keep creating new untitled notebooks.
274273
const fileName = `${localize.DataScience.untitledNotebookFileName()}-${untitledStorage.length + 1}.ipynb`;

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { CellState, ICell, IJupyterExecution, IJupyterKernelSpec, INotebookModel
2121
import detectIndent = require('detect-indent');
2222
// tslint:disable-next-line:no-require-imports no-var-requires
2323
import cloneDeep = require('lodash/cloneDeep');
24+
import { isFileNotFoundError } from '../../common/platform/errors';
2425
import { sendTelemetryEvent } from '../../telemetry';
2526
import { pruneCell } from '../common';
2627

@@ -34,6 +35,13 @@ interface INativeEditorStorageState {
3435
notebookJson: Partial<nbformat.INotebookContent>;
3536
}
3637

38+
function isUntitledFile(file?: Uri) {
39+
return file?.scheme === 'untitled';
40+
}
41+
export function isUntitled(model?: INotebookModel): boolean {
42+
return isUntitledFile(model?.file);
43+
}
44+
3745
class NativeEditorNotebookModel implements INotebookModel {
3846
public get onDidDispose() {
3947
return this._disposed.event;
@@ -52,14 +60,17 @@ class NativeEditorNotebookModel implements INotebookModel {
5260
}
5361

5462
public get isUntitled(): boolean {
55-
return this.file.scheme === 'untitled';
63+
return isUntitled(this);
5664
}
5765
public get cells(): ICell[] {
5866
return this._state.cells;
5967
}
6068
public get onDidEdit(): Event<NotebookModelChange> {
6169
return this._editEventEmitter.event;
6270
}
71+
public get metadata(): nbformat.INotebookMetadata | undefined {
72+
return this._state.notebookJson.metadata;
73+
}
6374
private _disposed = new EventEmitter<void>();
6475
private _isDisposed?: boolean;
6576
private _changedEmitter = new EventEmitter<NotebookModelChange>();
@@ -96,7 +107,6 @@ class NativeEditorNotebookModel implements INotebookModel {
96107
this._isDisposed = true;
97108
this._disposed.fire();
98109
}
99-
100110
public clone(file: Uri) {
101111
return new NativeEditorNotebookModel(
102112
file,
@@ -115,9 +125,6 @@ class NativeEditorNotebookModel implements INotebookModel {
115125
public async undoEdits(edits: readonly NotebookModelChange[]): Promise<void> {
116126
edits.forEach((e) => this.update({ ...e, source: 'undo' }));
117127
}
118-
public get metadata(): nbformat.INotebookMetadata | undefined {
119-
return this._state.notebookJson.metadata;
120-
}
121128

122129
public getContent(): string {
123130
return this.generateNotebookContent();
@@ -182,13 +189,18 @@ class NativeEditorNotebookModel implements INotebookModel {
182189
case 'save':
183190
this._state.saveChangeCount = this._state.changeCount;
184191
break;
192+
case 'saveAs':
193+
this._state.saveChangeCount = this._state.changeCount;
194+
this._state.changeCount = this._state.saveChangeCount = 0;
195+
this._state.file = change.target;
196+
break;
185197
default:
186198
break;
187199
}
188200

189201
// Dirty state comes from undo. At least VS code will track it that way. However
190202
// skip file changes as we don't forward those to VS code
191-
if (change.kind !== 'save') {
203+
if (change.kind !== 'save' && change.kind !== 'saveAs') {
192204
this._state.changeCount += 1;
193205
}
194206

@@ -433,6 +445,10 @@ class NativeEditorNotebookModel implements INotebookModel {
433445

434446
@injectable()
435447
export class NativeEditorStorage implements INotebookStorage {
448+
public get onSavedAs(): Event<{ new: Uri; old: Uri }> {
449+
return this.savedAs.event;
450+
}
451+
private readonly savedAs = new EventEmitter<{ new: Uri; old: Uri }>();
436452
constructor(
437453
@inject(IJupyterExecution) private jupyterExecution: IJupyterExecution,
438454
@inject(IFileSystem) private fileSystem: IFileSystem,
@@ -442,25 +458,35 @@ export class NativeEditorStorage implements INotebookStorage {
442458
@inject(IMemento) @named(WORKSPACE_MEMENTO) private localStorage: Memento
443459
) {}
444460
private static isUntitledFile(file: Uri) {
445-
return file.scheme === 'untitled';
461+
return isUntitledFile(file);
446462
}
447463

448464
public async load(file: Uri, possibleContents?: string): Promise<INotebookModel> {
449465
return this.loadFromFile(file, possibleContents);
450466
}
451467
public async save(model: INotebookModel, _cancellation: CancellationToken): Promise<void> {
452-
await this.saveAs(model, model.file);
468+
const contents = model.getContent();
469+
await this.fileSystem.writeFile(model.file.fsPath, contents, 'utf-8');
470+
model.update({
471+
source: 'user',
472+
kind: 'save',
473+
oldDirty: model.isDirty,
474+
newDirty: false
475+
});
453476
}
454477

455478
public async saveAs(model: INotebookModel, file: Uri): Promise<void> {
479+
const old = model.file;
456480
const contents = model.getContent();
457481
await this.fileSystem.writeFile(file.fsPath, contents, 'utf-8');
458482
model.update({
459483
source: 'user',
460-
kind: 'save',
484+
kind: 'saveAs',
461485
oldDirty: model.isDirty,
462-
newDirty: false
486+
newDirty: false,
487+
target: file
463488
});
489+
this.savedAs.fire({ new: file, old });
464490
}
465491
public async backup(model: INotebookModel, cancellation: CancellationToken): Promise<void> {
466492
// Should send to extension context storage path
@@ -657,7 +683,10 @@ export class NativeEditorStorage implements INotebookStorage {
657683
return data.contents;
658684
}
659685
} catch (exc) {
660-
traceError(`Exception reading from temporary storage for ${key}`, exc);
686+
// No need to log error if file doesn't exist.
687+
if (!isFileNotFoundError(exc)) {
688+
traceError(`Exception reading from temporary storage for ${key}`, exc);
689+
}
661690
}
662691
}
663692

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,29 @@
44
'use strict';
55

66
import { inject, injectable } from 'inversify';
7-
import { CancellationToken, CancellationTokenSource, Uri, WorkspaceConfiguration } from 'vscode';
7+
import { CancellationToken, CancellationTokenSource, EventEmitter, Uri, WorkspaceConfiguration } from 'vscode';
88
import { IWorkspaceService } from '../../common/application/types';
99
import { IDisposable, IDisposableRegistry } from '../../common/types';
10+
import { DataScience } from '../../common/utils/localize';
1011
import { noop } from '../../common/utils/misc';
1112
import { INotebookModel, INotebookStorage } from '../types';
13+
import { isUntitled } from './nativeEditorStorage';
1214

1315
// tslint:disable-next-line:no-require-imports no-var-requires
1416
const debounce = require('lodash/debounce') as typeof import('lodash/debounce');
1517

1618
export const INotebookStorageProvider = Symbol.for('INotebookStorageProvider');
17-
export interface INotebookStorageProvider extends INotebookStorage {}
19+
export interface INotebookStorageProvider extends INotebookStorage {
20+
createNew(contents?: string): Promise<INotebookModel>;
21+
}
1822
@injectable()
1923
export class NotebookStorageProvider implements INotebookStorageProvider {
24+
public get onSavedAs() {
25+
return this._savedAs.event;
26+
}
27+
private readonly _savedAs = new EventEmitter<{ new: Uri; old: Uri }>();
2028
private readonly storageAndModels = new Map<string, Promise<INotebookModel>>();
29+
private models = new Set<INotebookModel>();
2130
private readonly disposables: IDisposable[] = [];
2231
private readonly _autoSaveNotebookInHotExitFile = new WeakMap<INotebookModel, Function>();
2332
constructor(
@@ -26,13 +35,16 @@ export class NotebookStorageProvider implements INotebookStorageProvider {
2635
@inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService
2736
) {
2837
disposables.push(this);
38+
disposables.push(storage.onSavedAs((e) => this._savedAs.fire(e)));
2939
}
3040
public save(model: INotebookModel, cancellation: CancellationToken) {
3141
return this.storage.save(model, cancellation);
3242
}
3343
public async saveAs(model: INotebookModel, targetResource: Uri) {
44+
const oldUri = model.file;
3445
await this.storage.saveAs(model, targetResource);
3546
this.trackModel(model);
47+
this.storageAndModels.delete(oldUri.toString());
3648
this.storageAndModels.set(targetResource.toString(), Promise.resolve(model));
3749
}
3850
public backup(model: INotebookModel, cancellation: CancellationToken) {
@@ -52,11 +64,33 @@ export class NotebookStorageProvider implements INotebookStorageProvider {
5264
this.disposables.shift()?.dispose(); // NOSONAR
5365
}
5466
}
67+
68+
public async createNew(contents?: string): Promise<INotebookModel> {
69+
// Create a new URI for the dummy file using our root workspace path
70+
const uri = await this.getNextNewNotebookUri();
71+
return this.load(uri, contents);
72+
}
73+
74+
private async getNextNewNotebookUri(): Promise<Uri> {
75+
// tslint:disable-next-line: no-suspicious-comment
76+
// TODO: This will not work, if we close an untitled document.
77+
// See if we have any untitled storage already
78+
const untitledStorage = Array.from(this.models.values()).filter(isUntitled);
79+
// Just use the length (don't bother trying to fill in holes). We never remove storage objects from
80+
// our map, so we'll keep creating new untitled notebooks.
81+
const fileName = `${DataScience.untitledNotebookFileName()}-${untitledStorage.length + 1}.ipynb`;
82+
const fileUri = Uri.file(fileName);
83+
// Turn this back into an untitled
84+
return fileUri.with({ scheme: 'untitled', path: fileName });
85+
}
86+
5587
private trackModel(model: INotebookModel) {
5688
this.disposables.push(model);
89+
this.models.add(model);
5790
// When a model is no longer used, ensure we remove it from the cache.
5891
model.onDidDispose(
5992
() => {
93+
this.models.delete(model);
6094
this.storageAndModels.delete(model.file.toString());
6195
this._autoSaveNotebookInHotExitFile.delete(model);
6296
},

src/client/datascience/notebook/executionHelpers.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,25 @@ export function monitorModelCellOutputChangesAndUpdateNotebookDocument(
8989
document: NotebookDocument,
9090
model: INotebookModel
9191
): IDisposable {
92-
return model.changed((change) => {
92+
let wasUntitledNotebook = model.isUntitled;
93+
let stopSyncingOutput = false;
94+
const disposable = model.changed((change) => {
95+
if (stopSyncingOutput) {
96+
return;
97+
}
98+
if (change.kind === 'saveAs') {
99+
if (wasUntitledNotebook) {
100+
wasUntitledNotebook = false;
101+
// User saved untitled file as a real file.
102+
return;
103+
} else {
104+
// Ok, user save a normal notebook as another name.
105+
// Stop monitoring changes.
106+
stopSyncingOutput = true;
107+
disposable.dispose();
108+
return;
109+
}
110+
}
93111
// We're only interested in updates to cells.
94112
if (change.kind !== 'modify') {
95113
return;
@@ -117,6 +135,8 @@ export function monitorModelCellOutputChangesAndUpdateNotebookDocument(
117135
uiCellToUpdate.outputs = newOutput;
118136
}
119137
});
138+
139+
return disposable;
120140
}
121141

122142
/**

src/client/datascience/notebook/notebookEditorProvider.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ export class NotebookEditorProvider implements INotebookEditorProvider {
8181
this.disposables.push(notebook.onDidOpenNotebookDocument(this.onDidOpenNotebookDocument, this));
8282
this.disposables.push(notebook.onDidCloseNotebookDocument(this.onDidCloseNotebookDocument, this));
8383

84+
// Swap the uris.
85+
this.disposables.push(
86+
this.storage.onSavedAs((e) => {
87+
const savedEditor = this.notebookEditorsByUri.get(e.old.toString());
88+
if (savedEditor) {
89+
this.notebookEditorsByUri.delete(e.old.toString());
90+
this.notebookEditorsByUri.set(e.new.toString(), savedEditor);
91+
}
92+
})
93+
);
94+
8495
// Look through the file system for ipynb files to see how many we have in the workspace. Don't wait
8596
// on this though.
8697
const findFilesPromise = this.workspace.findFiles('**/*.ipynb');
@@ -114,18 +125,15 @@ export class NotebookEditorProvider implements INotebookEditorProvider {
114125
// We do not need this.
115126
throw new Error('Not supported');
116127
}
117-
public async createNew(_contents?: string): Promise<INotebookEditor> {
118-
// tslint:disable-next-line: no-suspicious-comment
119-
// TODO: In another branch.
120-
// const model = await this.storage.createNew(contents);
121-
// await this.onDidOpenNotebookDocument(model.file);
128+
public async createNew(contents?: string): Promise<INotebookEditor> {
129+
const model = await this.storage.createNew(contents);
130+
await this.onDidOpenNotebookDocument(model.file);
122131
// tslint:disable-next-line: no-suspicious-comment
123132
// TODO: Need to do this.
124133
// Update number of notebooks in the workspace
125134
// this.notebookCount += 1;
126-
// return this.open(model.file);
127-
// tslint:disable-next-line: no-any
128-
return undefined as any;
135+
136+
return this.open(model.file);
129137
}
130138
protected openedEditor(editor: INotebookEditor): void {
131139
this.openedNotebookCount += 1;

0 commit comments

Comments
 (0)