Skip to content

Commit ff55375

Browse files
authored
Add support for attachment cleaning on notebook save (#179178)
* support for `onWillSave` + debounce rework * dispose of delayer + tabs not spaces... * adjust for API naming change, rm proposal refs
1 parent 4742687 commit ff55375

File tree

3 files changed

+119
-75
lines changed

3 files changed

+119
-75
lines changed

extensions/ipynb/src/helper.ts

Lines changed: 52 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -77,61 +77,66 @@ export function objectEquals(one: any, other: any) {
7777
return true;
7878
}
7979

80-
interface Options<T> {
81-
callback: (value: T) => void;
82-
83-
merge?: (input: T[]) => T;
84-
delay?: number;
85-
}
86-
87-
88-
export class DebounceTrigger<T> {
89-
90-
private _isPaused = 0;
91-
protected _queue: T[] = [];
92-
private _callbackFn: (value: T) => void;
93-
private _mergeFn?: (input: T[]) => T;
94-
private readonly _delay: number;
95-
private _handle: any | undefined;
96-
97-
constructor(options: Options<T>) {
98-
this._callbackFn = options.callback;
99-
this._mergeFn = options.merge;
100-
this._delay = options.delay ?? 100;
80+
/**
81+
* A helper to delay/debounce execution of a task, includes cancellation/disposal support.
82+
* Pulled from https://github.com/microsoft/vscode/blob/3059063b805ed0ac10a6d9539e213386bfcfb852/extensions/markdown-language-features/src/util/async.ts
83+
*/
84+
export class Delayer<T> {
85+
86+
public defaultDelay: number;
87+
private _timeout: any; // Timer
88+
private _cancelTimeout: Promise<T | null> | null;
89+
private _onSuccess: ((value: T | PromiseLike<T> | undefined) => void) | null;
90+
private _task: ITask<T> | null;
91+
92+
constructor(defaultDelay: number) {
93+
this.defaultDelay = defaultDelay;
94+
this._timeout = null;
95+
this._cancelTimeout = null;
96+
this._onSuccess = null;
97+
this._task = null;
10198
}
10299

103-
private pause(): void {
104-
this._isPaused++;
100+
dispose() {
101+
this._doCancelTimeout();
105102
}
106103

107-
private resume(): void {
108-
if (this._isPaused !== 0 && --this._isPaused === 0) {
109-
if (this._mergeFn) {
110-
const items = Array.from(this._queue);
111-
this._queue = [];
112-
this._callbackFn(this._mergeFn(items));
113-
114-
} else {
115-
while (!this._isPaused && this._queue.length !== 0) {
116-
this._callbackFn(this._queue.shift()!);
117-
}
118-
}
104+
public trigger(task: ITask<T>, delay: number = this.defaultDelay): Promise<T | null> {
105+
this._task = task;
106+
if (delay >= 0) {
107+
this._doCancelTimeout();
119108
}
120-
}
121109

122-
trigger(item: T): void {
123-
if (!this._handle) {
124-
this.pause();
125-
this._handle = setTimeout(() => {
126-
this._handle = undefined;
127-
this.resume();
128-
}, this._delay);
110+
if (!this._cancelTimeout) {
111+
this._cancelTimeout = new Promise<T | undefined>((resolve) => {
112+
this._onSuccess = resolve;
113+
}).then(() => {
114+
this._cancelTimeout = null;
115+
this._onSuccess = null;
116+
const result = this._task && this._task?.();
117+
this._task = null;
118+
return result;
119+
});
129120
}
130121

131-
if (this._isPaused !== 0) {
132-
this._queue.push(item);
133-
} else {
134-
this._callbackFn(item);
122+
if (delay >= 0 || this._timeout === null) {
123+
this._timeout = setTimeout(() => {
124+
this._timeout = null;
125+
this._onSuccess?.(undefined);
126+
}, delay >= 0 ? delay : this.defaultDelay);
135127
}
128+
129+
return this._cancelTimeout;
136130
}
131+
132+
private _doCancelTimeout(): void {
133+
if (this._timeout !== null) {
134+
clearTimeout(this._timeout);
135+
this._timeout = null;
136+
}
137+
}
138+
}
139+
140+
export interface ITask<T> {
141+
(): T;
137142
}

extensions/ipynb/src/notebookAttachmentCleaner.ts

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import * as vscode from 'vscode';
77
import { ATTACHMENT_CLEANUP_COMMANDID, JUPYTER_NOTEBOOK_MARKDOWN_SELECTOR } from './constants';
8-
import { DebounceTrigger, deepClone, objectEquals } from './helper';
8+
import { deepClone, objectEquals, Delayer } from './helper';
99

1010
interface AttachmentCleanRequest {
1111
notebook: vscode.NotebookDocument;
@@ -32,15 +32,10 @@ export class AttachmentCleaner implements vscode.CodeActionProvider {
3232

3333
private _disposables: vscode.Disposable[];
3434
private _imageDiagnosticCollection: vscode.DiagnosticCollection;
35+
private readonly _delayer = new Delayer(750);
36+
3537
constructor() {
3638
this._disposables = [];
37-
const debounceTrigger = new DebounceTrigger<AttachmentCleanRequest>({
38-
callback: (change: AttachmentCleanRequest) => {
39-
this.cleanNotebookAttachments(change);
40-
},
41-
delay: 500
42-
});
43-
4439
this._imageDiagnosticCollection = vscode.languages.createDiagnosticCollection('Notebook Image Attachment');
4540
this._disposables.push(this._imageDiagnosticCollection);
4641

@@ -57,23 +52,66 @@ export class AttachmentCleaner implements vscode.CodeActionProvider {
5752
}));
5853

5954
this._disposables.push(vscode.workspace.onDidChangeNotebookDocument(e => {
60-
e.cellChanges.forEach(change => {
61-
if (!change.document) {
62-
return;
63-
}
55+
this._delayer.trigger(() => {
6456

65-
if (change.cell.kind !== vscode.NotebookCellKind.Markup) {
66-
return;
67-
}
57+
e.cellChanges.forEach(change => {
58+
if (!change.document) {
59+
return;
60+
}
6861

69-
debounceTrigger.trigger({
70-
notebook: e.notebook,
71-
cell: change.cell,
72-
document: change.document
62+
if (change.cell.kind !== vscode.NotebookCellKind.Markup) {
63+
return;
64+
}
65+
66+
const metadataEdit = this.cleanNotebookAttachments({
67+
notebook: e.notebook,
68+
cell: change.cell,
69+
document: change.document
70+
});
71+
if (metadataEdit) {
72+
const workspaceEdit = new vscode.WorkspaceEdit();
73+
workspaceEdit.set(e.notebook.uri, [metadataEdit]);
74+
vscode.workspace.applyEdit(workspaceEdit);
75+
}
7376
});
7477
});
7578
}));
7679

80+
81+
this._disposables.push(vscode.workspace.onWillSaveNotebookDocument(e => {
82+
if (e.reason === vscode.TextDocumentSaveReason.Manual) {
83+
this._delayer.dispose();
84+
85+
e.waitUntil(new Promise((resolve) => {
86+
if (e.notebook.getCells().length === 0) {
87+
return;
88+
}
89+
90+
const notebookEdits: vscode.NotebookEdit[] = [];
91+
for (const cell of e.notebook.getCells()) {
92+
if (cell.kind !== vscode.NotebookCellKind.Markup) {
93+
continue;
94+
}
95+
96+
const metadataEdit = this.cleanNotebookAttachments({
97+
notebook: e.notebook,
98+
cell: cell,
99+
document: cell.document
100+
});
101+
102+
if (metadataEdit) {
103+
notebookEdits.push(metadataEdit);
104+
}
105+
}
106+
107+
const workspaceEdit = new vscode.WorkspaceEdit();
108+
workspaceEdit.set(e.notebook.uri, notebookEdits);
109+
110+
resolve(workspaceEdit);
111+
}));
112+
}
113+
}));
114+
77115
this._disposables.push(vscode.workspace.onDidCloseNotebookDocument(e => {
78116
this._attachmentCache.delete(e.uri.toString());
79117
}));
@@ -134,8 +172,10 @@ export class AttachmentCleaner implements vscode.CodeActionProvider {
134172
/**
135173
* take in a NotebookDocumentChangeEvent, and clean the attachment data for the cell(s) that have had their markdown source code changed
136174
* @param e NotebookDocumentChangeEvent from the onDidChangeNotebookDocument listener
175+
* @returns vscode.NotebookEdit, the metadata alteration performed on the json behind the ipynb
137176
*/
138-
private cleanNotebookAttachments(e: AttachmentCleanRequest) {
177+
private cleanNotebookAttachments(e: AttachmentCleanRequest): vscode.NotebookEdit | undefined {
178+
139179
if (e.notebook.isClosed) {
140180
return;
141181
}
@@ -187,16 +227,16 @@ export class AttachmentCleaner implements vscode.CodeActionProvider {
187227
}
188228
}
189229

230+
this.updateDiagnostics(cell.document.uri, diagnostics);
231+
190232
if (cell.index > -1 && !objectEquals(markdownAttachmentsInUse, cell.metadata.attachments)) {
191233
const updateMetadata: { [key: string]: any } = deepClone(cell.metadata);
192234
updateMetadata.attachments = markdownAttachmentsInUse;
193235
const metadataEdit = vscode.NotebookEdit.updateCellMetadata(cell.index, updateMetadata);
194-
const workspaceEdit = new vscode.WorkspaceEdit();
195-
workspaceEdit.set(e.notebook.uri, [metadataEdit]);
196-
vscode.workspace.applyEdit(workspaceEdit);
197-
}
198236

199-
this.updateDiagnostics(cell.document.uri, diagnostics);
237+
return metadataEdit;
238+
}
239+
return;
200240
}
201241

202242
private analyzeMissingAttachments(document: vscode.TextDocument): void {
@@ -345,6 +385,7 @@ export class AttachmentCleaner implements vscode.CodeActionProvider {
345385

346386
dispose() {
347387
this._disposables.forEach(d => d.dispose());
388+
this._delayer.dispose();
348389
}
349390
}
350391

extensions/ipynb/tsconfig.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
"extends": "../tsconfig.base.json",
33
"compilerOptions": {
44
"outDir": "./out",
5-
"lib": [
6-
"dom"
7-
]
5+
"lib": ["dom"]
86
},
97
"include": [
108
"src/**/*",

0 commit comments

Comments
 (0)