Skip to content

Commit c5cb1ef

Browse files
authored
Auto save native editor notebook (microsoft#7831)
1 parent 6dba0e4 commit c5cb1ef

19 files changed

Lines changed: 874 additions & 371 deletions

File tree

news/1 Enhancements/7831.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added ability to auto-save chagnes made to the notebook.

src/client/common/application/applicationShell.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
// tslint:disable:no-var-requires no-any unified-signatures
66

77
import { injectable } from 'inversify';
8-
import { CancellationToken, Disposable, env, InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, ProgressOptions, QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, TreeView, TreeViewOptions, Uri, window, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode';
8+
import { CancellationToken, Disposable, env, Event, InputBox, InputBoxOptions, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, ProgressOptions, QuickPick, QuickPickItem, QuickPickOptions, SaveDialogOptions, StatusBarAlignment, StatusBarItem, TreeView, TreeViewOptions, Uri, window, WindowState, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode';
99
import { IApplicationShell } from './types';
1010

1111
@injectable()
1212
export class ApplicationShell implements IApplicationShell {
13+
public get onDidChangeWindowState(): Event<WindowState> {
14+
return window.onDidChangeWindowState;
15+
}
1316
public showInformationMessage(message: string, ...items: string[]): Thenable<string>;
1417
public showInformationMessage(message: string, options: MessageOptions, ...items: string[]): Thenable<string>;
1518
public showInformationMessage<T extends MessageItem>(message: string, ...items: T[]): Thenable<T>;
@@ -81,5 +84,4 @@ export class ApplicationShell implements IApplicationShell {
8184
public createOutputChannel(name: string): OutputChannel {
8285
return window.createOutputChannel(name);
8386
}
84-
8587
}

src/client/common/application/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
TreeViewOptions,
5050
Uri,
5151
ViewColumn,
52+
WindowState,
5253
WorkspaceConfiguration,
5354
WorkspaceEdit,
5455
WorkspaceFolder,
@@ -64,6 +65,12 @@ import { ICommandNameArgumentTypeMapping } from './commands';
6465

6566
export const IApplicationShell = Symbol('IApplicationShell');
6667
export interface IApplicationShell {
68+
/**
69+
* An [event](#Event) which fires when the focus state of the current window
70+
* changes. The value of the event represents whether the window is focused.
71+
*/
72+
readonly onDidChangeWindowState: Event<WindowState>;
73+
6774
showInformationMessage(message: string, ...items: string[]): Thenable<string | undefined>;
6875

6976
/**

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export namespace InteractiveWindowMessages {
2727
export const Interrupt = 'interrupt';
2828
export const SubmitNewCell = 'submit_new_cell';
2929
export const UpdateSettings = SharedMessages.UpdateSettings;
30+
// Message sent to React component from extension asking it to save the notebook.
31+
export const DoSave = 'DoSave';
3032
export const SendInfo = 'send_info';
3133
export const Started = SharedMessages.Started;
3234
export const AddedSysInfo = 'added_sys_info';
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
'use strict';
5+
6+
import { inject, injectable } from 'inversify';
7+
import { ConfigurationChangeEvent, Event, EventEmitter, TextEditor, Uri } from 'vscode';
8+
import { IApplicationShell, IDocumentManager, IWorkspaceService } from '../../common/application/types';
9+
import '../../common/extensions';
10+
import { traceError } from '../../common/logger';
11+
import { IFileSystem } from '../../common/platform/types';
12+
import { IDisposable } from '../../common/types';
13+
import { INotebookIdentity, InteractiveWindowMessages } from '../interactive-common/interactiveWindowTypes';
14+
import { FileSettings, IInteractiveWindowListener, INotebookEditor, INotebookEditorProvider } from '../types';
15+
16+
// tslint:disable: no-any
17+
18+
/**
19+
* Sends notifications to Notebooks to save the notebook.
20+
* Based on auto save settings, this class will regularly check for changes and send a save requet.
21+
* If window state changes or active editor changes, then notify notebooks (if auto save is configured to do so).
22+
* Monitor save and modified events on editor to determine its current dirty state.
23+
*
24+
* @export
25+
* @class AutoSaveService
26+
* @implements {IInteractiveWindowListener}
27+
*/
28+
@injectable()
29+
export class AutoSaveService implements IInteractiveWindowListener {
30+
private postEmitter: EventEmitter<{ message: string; payload: any }> = new EventEmitter<{ message: string; payload: any }>();
31+
private disposables: IDisposable[] = [];
32+
private notebookUri?: Uri;
33+
private timeout?: ReturnType<typeof setTimeout>;
34+
constructor(
35+
@inject(IApplicationShell) appShell: IApplicationShell,
36+
@inject(IDocumentManager) documentManager: IDocumentManager,
37+
@inject(INotebookEditorProvider) private readonly notebookProvider: INotebookEditorProvider,
38+
@inject(IFileSystem) private readonly fileSystem: IFileSystem,
39+
@inject(IWorkspaceService) private readonly workspace: IWorkspaceService
40+
) {
41+
this.workspace.onDidChangeConfiguration(this.onSettingsChanded.bind(this), this, this.disposables);
42+
this.disposables.push(appShell.onDidChangeWindowState(this.onDidChangeWindowState.bind(this)));
43+
this.disposables.push(documentManager.onDidChangeActiveTextEditor(this.onDidChangeActiveTextEditor.bind(this)));
44+
}
45+
46+
public get postMessage(): Event<{ message: string; payload: any }> {
47+
return this.postEmitter.event;
48+
}
49+
50+
public onMessage(message: string, payload?: any): void {
51+
if (message === InteractiveWindowMessages.NotebookIdentity) {
52+
this.notebookUri = Uri.parse((payload as INotebookIdentity).resource);
53+
}
54+
if (message === InteractiveWindowMessages.LoadAllCellsComplete) {
55+
const notebook = this.getNotebook();
56+
if (!notebook) {
57+
traceError(`Received message ${message}, but there is no notebook for ${this.notebookUri ? this.notebookUri.fsPath : undefined}`);
58+
return;
59+
}
60+
this.disposables.push(notebook.modified(this.onNotebookModified, this, this.disposables));
61+
this.disposables.push(notebook.saved(this.onNotebookSaved, this, this.disposables));
62+
}
63+
}
64+
public dispose(): void | undefined {
65+
this.disposables.filter(item => !!item).forEach(item => item.dispose());
66+
this.clearTimeout();
67+
}
68+
private onNotebookModified(_: INotebookEditor) {
69+
// If we haven't started a timer, then start if necessary.
70+
if (!this.timeout) {
71+
this.setTimer();
72+
}
73+
}
74+
private onNotebookSaved(_: INotebookEditor) {
75+
// If we haven't started a timer, then start if necessary.
76+
if (!this.timeout) {
77+
this.setTimer();
78+
}
79+
}
80+
private getNotebook(): INotebookEditor | undefined {
81+
const uri = this.notebookUri;
82+
if (!uri) {
83+
return;
84+
}
85+
return this.notebookProvider.editors.find(item => this.fileSystem.arePathsSame(item.file.fsPath, uri.fsPath));
86+
}
87+
private getAutoSaveSettings(): FileSettings {
88+
const filesConfig = this.workspace.getConfiguration('files', this.notebookUri);
89+
return {
90+
autoSave: filesConfig.get('autoSave', 'off'),
91+
autoSaveDelay: filesConfig.get('autoSaveDelay', 1000)
92+
};
93+
}
94+
private onSettingsChanded(e: ConfigurationChangeEvent) {
95+
if (e.affectsConfiguration('files.autoSave') || e.affectsConfiguration('files.autoSaveDelay')) {
96+
// Reset the timer, as we may have increased it, turned it off or other.
97+
this.clearTimeout();
98+
this.setTimer();
99+
}
100+
}
101+
private setTimer() {
102+
const settings = this.getAutoSaveSettings();
103+
if (!settings || settings.autoSave === 'off') {
104+
return;
105+
}
106+
if (settings && settings.autoSave === 'afterDelay') {
107+
// Add a timeout to save after n milli seconds.
108+
// Do not use setInterval, as that will cause all handlers to queue up.
109+
this.timeout = setTimeout(() => {
110+
this.save();
111+
}, settings.autoSaveDelay);
112+
}
113+
}
114+
private clearTimeout() {
115+
if (this.timeout) {
116+
clearTimeout(this.timeout);
117+
this.timeout = undefined;
118+
}
119+
}
120+
private save() {
121+
this.clearTimeout();
122+
const notebook = this.getNotebook();
123+
if (notebook && notebook.isDirty) {
124+
// Notify webview to perform a save.
125+
this.postEmitter.fire({ message: InteractiveWindowMessages.DoSave, payload: undefined });
126+
} else {
127+
this.setTimer();
128+
}
129+
}
130+
private onDidChangeWindowState() {
131+
const settings = this.getAutoSaveSettings();
132+
if (settings && settings.autoSave === 'onWindowChange') {
133+
this.save();
134+
}
135+
}
136+
private onDidChangeActiveTextEditor(_e?: TextEditor) {
137+
const settings = this.getAutoSaveSettings();
138+
if (settings && settings.autoSave === 'onFocusChange') {
139+
this.save();
140+
}
141+
}
142+
}

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

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,7 @@ import * as path from 'path';
99
import * as uuid from 'uuid/v4';
1010
import { Event, EventEmitter, Memento, Uri, ViewColumn } from 'vscode';
1111

12-
import {
13-
IApplicationShell,
14-
ICommandManager,
15-
IDocumentManager,
16-
ILiveShareApi,
17-
IWebPanelProvider,
18-
IWorkspaceService
19-
} from '../../common/application/types';
12+
import { IApplicationShell, ICommandManager, IDocumentManager, ILiveShareApi, IWebPanelProvider, IWorkspaceService } from '../../common/application/types';
2013
import { ContextKey } from '../../common/contextKey';
2114
import { traceError } from '../../common/logger';
2215
import { IFileSystem, TemporaryFile } from '../../common/platform/types';
@@ -28,21 +21,9 @@ import { EXTENSION_ROOT_DIR } from '../../constants';
2821
import { IInterpreterService } from '../../interpreter/contracts';
2922
import { captureTelemetry, sendTelemetryEvent } from '../../telemetry';
3023
import { concatMultilineString } from '../common';
31-
import {
32-
EditorContexts,
33-
Identifiers,
34-
NativeKeyboardCommandTelemetryLookup,
35-
NativeMouseCommandTelemetryLookup,
36-
Telemetry
37-
} from '../constants';
24+
import { EditorContexts, Identifiers, NativeKeyboardCommandTelemetryLookup, NativeMouseCommandTelemetryLookup, Telemetry } from '../constants';
3825
import { InteractiveBase } from '../interactive-common/interactiveBase';
39-
import {
40-
IEditCell,
41-
INativeCommand,
42-
InteractiveWindowMessages,
43-
ISaveAll,
44-
ISubmitNewCell
45-
} from '../interactive-common/interactiveWindowTypes';
26+
import { IEditCell, INativeCommand, InteractiveWindowMessages, ISaveAll, ISubmitNewCell } from '../interactive-common/interactiveWindowTypes';
4627
import {
4728
CellState,
4829
ICell,
@@ -74,6 +55,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
7455
private closedEvent: EventEmitter<INotebookEditor> = new EventEmitter<INotebookEditor>();
7556
private executedEvent: EventEmitter<INotebookEditor> = new EventEmitter<INotebookEditor>();
7657
private modifiedEvent: EventEmitter<INotebookEditor> = new EventEmitter<INotebookEditor>();
58+
private savedEvent: EventEmitter<INotebookEditor> = new EventEmitter<INotebookEditor>();
7759
private loadedPromise: Deferred<void> = createDeferred<void>();
7860
private _file: Uri = Uri.file('');
7961
private _dirty: boolean = false;
@@ -129,7 +111,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
129111
errorHandler,
130112
path.join(EXTENSION_ROOT_DIR, 'out', 'datascience-ui', 'native-editor', 'index_bundle.js'),
131113
localize.DataScience.nativeEditorTitle(),
132-
ViewColumn.Active);
114+
ViewColumn.Active
115+
);
133116
}
134117

135118
public get visible(): boolean {
@@ -187,6 +170,14 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
187170
return this.modifiedEvent.event;
188171
}
189172

173+
public get saved(): Event<INotebookEditor> {
174+
return this.savedEvent.event;
175+
}
176+
177+
public get isDirty(): boolean {
178+
return this._dirty;
179+
}
180+
190181
// tslint:disable-next-line: no-any
191182
public onMessage(message: string, payload: any) {
192183
super.onMessage(message, payload);
@@ -269,9 +260,19 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
269260
this.submitCode(info.code, Identifiers.EmptyFileName, 0, info.id).ignoreErrors();
270261

271262
// Activate the other side, and send as if came from a file
272-
this.ipynbProvider.show(this.file).then(_v => {
273-
this.shareMessage(InteractiveWindowMessages.RemoteAddCode, { code: info.code, file: Identifiers.EmptyFileName, line: 0, id: info.id, originator: this.id, debug: false });
274-
}).ignoreErrors();
263+
this.ipynbProvider
264+
.show(this.file)
265+
.then(_v => {
266+
this.shareMessage(InteractiveWindowMessages.RemoteAddCode, {
267+
code: info.code,
268+
file: Identifiers.EmptyFileName,
269+
line: 0,
270+
id: info.id,
271+
originator: this.id,
272+
debug: false
273+
});
274+
})
275+
.ignoreErrors();
275276
}
276277
}
277278

@@ -289,7 +290,14 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
289290

290291
// Activate the other side, and send as if came from a file
291292
await this.ipynbProvider.show(this.file);
292-
this.shareMessage(InteractiveWindowMessages.RemoteReexecuteCode, { code: info.code, file: Identifiers.EmptyFileName, line: 0, id: info.id, originator: this.id, debug: false });
293+
this.shareMessage(InteractiveWindowMessages.RemoteReexecuteCode, {
294+
code: info.code,
295+
file: Identifiers.EmptyFileName,
296+
line: 0,
297+
id: info.id,
298+
originator: this.id,
299+
debug: false
300+
});
293301
}
294302
} catch (exc) {
295303
// Make this error our cell output
@@ -298,10 +306,12 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
298306
data: {
299307
source: info.code,
300308
cell_type: 'code',
301-
outputs: [{
302-
output_type: 'error',
303-
evalue: exc.toString()
304-
}],
309+
outputs: [
310+
{
311+
output_type: 'error',
312+
evalue: exc.toString()
313+
}
314+
],
305315
metadata: {},
306316
execution_count: null
307317
},
@@ -318,7 +328,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
318328

319329
// Handle an error
320330
await this.errorHandler.handleError(exc);
321-
322331
}
323332
}
324333

@@ -395,8 +404,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
395404
cell_type: 'code',
396405
outputs: [],
397406
source: [],
398-
metadata: {
399-
},
407+
metadata: {},
400408
execution_count: null
401409
}
402410
};
@@ -570,7 +578,6 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
570578
if (contents) {
571579
await this.viewDocument(contents);
572580
}
573-
574581
} catch (e) {
575582
await this.errorHandler.handleError(e);
576583
} finally {
@@ -615,8 +622,8 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor {
615622
// Update our file name and dirty state
616623
this._file = fileToSaveTo;
617624
await this.setClean();
625+
this.savedEvent.fire(this);
618626
}
619-
620627
} catch (e) {
621628
traceError(e);
622629
}

src/client/datascience/serviceRegistry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { DotNetIntellisenseProvider } from './interactive-common/intellisense/do
2525
import { JediIntellisenseProvider } from './interactive-common/intellisense/jediIntellisenseProvider';
2626
import { LinkProvider } from './interactive-common/linkProvider';
2727
import { ShowPlotListener } from './interactive-common/showPlotListener';
28+
import { AutoSaveService } from './interactive-ipynb/autoSaveService';
2829
import { NativeEditor } from './interactive-ipynb/nativeEditor';
2930
import { NativeEditorCommandListener } from './interactive-ipynb/nativeEditorCommandListener';
3031
import { NativeEditorProvider } from './interactive-ipynb/nativeEditorProvider';
@@ -123,6 +124,7 @@ export function registerTypes(serviceManager: IServiceManager) {
123124
serviceManager.add<IInteractiveWindowListener>(IInteractiveWindowListener, wrapType(ShowPlotListener));
124125
serviceManager.add<IInteractiveWindowListener>(IInteractiveWindowListener, wrapType(DebugListener));
125126
serviceManager.add<IInteractiveWindowListener>(IInteractiveWindowListener, wrapType(GatherListener));
127+
serviceManager.add<IInteractiveWindowListener>(IInteractiveWindowListener, wrapType(AutoSaveService));
126128
serviceManager.addSingleton<IPlotViewerProvider>(IPlotViewerProvider, wrapType(PlotViewerProvider));
127129
serviceManager.add<IPlotViewer>(IPlotViewer, wrapType(PlotViewer));
128130
serviceManager.addSingleton<IJupyterDebugger>(IJupyterDebugger, wrapType(JupyterDebugger));

0 commit comments

Comments
 (0)