Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/browser/mainThreadCustomEditors.ts
5226 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { multibyteAwareBtoa } from '../../../base/common/strings.js';
7
import { CancelablePromise, createCancelablePromise } from '../../../base/common/async.js';
8
import { VSBuffer } from '../../../base/common/buffer.js';
9
import { CancellationToken } from '../../../base/common/cancellation.js';
10
import { isCancellationError, onUnexpectedError } from '../../../base/common/errors.js';
11
import { Emitter, Event } from '../../../base/common/event.js';
12
import { Disposable, DisposableMap, DisposableStore, IReference } from '../../../base/common/lifecycle.js';
13
import { Schemas } from '../../../base/common/network.js';
14
import { basename } from '../../../base/common/path.js';
15
import { isEqual, isEqualOrParent, toLocalResource } from '../../../base/common/resources.js';
16
import { URI, UriComponents } from '../../../base/common/uri.js';
17
import { generateUuid } from '../../../base/common/uuid.js';
18
import { localize } from '../../../nls.js';
19
import { IFileDialogService } from '../../../platform/dialogs/common/dialogs.js';
20
import { FileOperation, IFileService } from '../../../platform/files/common/files.js';
21
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
22
import { ILabelService } from '../../../platform/label/common/label.js';
23
import { IStorageService } from '../../../platform/storage/common/storage.js';
24
import { IUndoRedoService, UndoRedoElementType } from '../../../platform/undoRedo/common/undoRedo.js';
25
import { MainThreadWebviewPanels } from './mainThreadWebviewPanels.js';
26
import { MainThreadWebviews, reviveWebviewExtension } from './mainThreadWebviews.js';
27
import * as extHostProtocol from '../common/extHost.protocol.js';
28
import { IRevertOptions, ISaveOptions } from '../../common/editor.js';
29
import { CustomEditorInput } from '../../contrib/customEditor/browser/customEditorInput.js';
30
import { CustomDocumentBackupData } from '../../contrib/customEditor/browser/customEditorInputFactory.js';
31
import { ICustomEditorModel, ICustomEditorService } from '../../contrib/customEditor/common/customEditor.js';
32
import { CustomTextEditorModel } from '../../contrib/customEditor/common/customTextEditorModel.js';
33
import { ExtensionKeyedWebviewOriginStore, WebviewExtensionDescription } from '../../contrib/webview/browser/webview.js';
34
import { WebviewInput } from '../../contrib/webviewPanel/browser/webviewEditorInput.js';
35
import { IWebviewWorkbenchService } from '../../contrib/webviewPanel/browser/webviewWorkbenchService.js';
36
import { editorGroupToColumn } from '../../services/editor/common/editorGroupColumn.js';
37
import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js';
38
import { IEditorService } from '../../services/editor/common/editorService.js';
39
import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js';
40
import { IExtensionService } from '../../services/extensions/common/extensions.js';
41
import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
42
import { IPathService } from '../../services/path/common/pathService.js';
43
import { ResourceWorkingCopy } from '../../services/workingCopy/common/resourceWorkingCopy.js';
44
import { IWorkingCopy, IWorkingCopyBackup, IWorkingCopySaveEvent, NO_TYPE_ID, WorkingCopyCapabilities } from '../../services/workingCopy/common/workingCopy.js';
45
import { IWorkingCopyFileService, WorkingCopyFileEvent } from '../../services/workingCopy/common/workingCopyFileService.js';
46
import { IWorkingCopyService } from '../../services/workingCopy/common/workingCopyService.js';
47
import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js';
48
49
const enum CustomEditorModelType {
50
Custom,
51
Text,
52
}
53
54
export class MainThreadCustomEditors extends Disposable implements extHostProtocol.MainThreadCustomEditorsShape {
55
56
private readonly _proxyCustomEditors: extHostProtocol.ExtHostCustomEditorsShape;
57
58
private readonly _editorProviders = this._register(new DisposableMap<string>());
59
60
private readonly _editorRenameBackups = new Map<string, CustomDocumentBackupData>();
61
62
private readonly _webviewOriginStore: ExtensionKeyedWebviewOriginStore;
63
64
constructor(
65
context: IExtHostContext,
66
private readonly mainThreadWebview: MainThreadWebviews,
67
private readonly mainThreadWebviewPanels: MainThreadWebviewPanels,
68
@IExtensionService extensionService: IExtensionService,
69
@IStorageService storageService: IStorageService,
70
@IWorkingCopyService workingCopyService: IWorkingCopyService,
71
@IWorkingCopyFileService workingCopyFileService: IWorkingCopyFileService,
72
@ICustomEditorService private readonly _customEditorService: ICustomEditorService,
73
@IEditorGroupsService private readonly _editorGroupService: IEditorGroupsService,
74
@IEditorService private readonly _editorService: IEditorService,
75
@IInstantiationService private readonly _instantiationService: IInstantiationService,
76
@IWebviewWorkbenchService private readonly _webviewWorkbenchService: IWebviewWorkbenchService,
77
@IUriIdentityService private readonly _uriIdentityService: IUriIdentityService,
78
) {
79
super();
80
81
this._webviewOriginStore = new ExtensionKeyedWebviewOriginStore('mainThreadCustomEditors.origins', storageService);
82
83
this._proxyCustomEditors = context.getProxy(extHostProtocol.ExtHostContext.ExtHostCustomEditors);
84
85
this._register(workingCopyFileService.registerWorkingCopyProvider((editorResource) => {
86
const matchedWorkingCopies: IWorkingCopy[] = [];
87
88
for (const workingCopy of workingCopyService.workingCopies) {
89
if (workingCopy instanceof MainThreadCustomEditorModel) {
90
if (isEqualOrParent(editorResource, workingCopy.editorResource)) {
91
matchedWorkingCopies.push(workingCopy);
92
}
93
}
94
}
95
return matchedWorkingCopies;
96
}));
97
98
// This reviver's only job is to activate custom editor extensions.
99
this._register(_webviewWorkbenchService.registerResolver({
100
canResolve: (webview: WebviewInput) => {
101
if (webview instanceof CustomEditorInput) {
102
extensionService.activateByEvent(`onCustomEditor:${webview.viewType}`);
103
}
104
return false;
105
},
106
resolveWebview: () => { throw new Error('not implemented'); }
107
}));
108
109
// Working copy operations
110
this._register(workingCopyFileService.onWillRunWorkingCopyFileOperation(async e => this.onWillRunWorkingCopyFileOperation(e)));
111
}
112
113
public $registerTextEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, capabilities: extHostProtocol.CustomTextEditorCapabilities, serializeBuffersForPostMessage: boolean): void {
114
this.registerEditorProvider(CustomEditorModelType.Text, reviveWebviewExtension(extensionData), viewType, options, capabilities, true, serializeBuffersForPostMessage);
115
}
116
117
public $registerCustomEditorProvider(extensionData: extHostProtocol.WebviewExtensionDescription, viewType: string, options: extHostProtocol.IWebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean, serializeBuffersForPostMessage: boolean): void {
118
this.registerEditorProvider(CustomEditorModelType.Custom, reviveWebviewExtension(extensionData), viewType, options, {}, supportsMultipleEditorsPerDocument, serializeBuffersForPostMessage);
119
}
120
121
private registerEditorProvider(
122
modelType: CustomEditorModelType,
123
extension: WebviewExtensionDescription,
124
viewType: string,
125
options: extHostProtocol.IWebviewPanelOptions,
126
capabilities: extHostProtocol.CustomTextEditorCapabilities,
127
supportsMultipleEditorsPerDocument: boolean,
128
serializeBuffersForPostMessage: boolean,
129
): void {
130
if (this._editorProviders.has(viewType)) {
131
throw new Error(`Provider for ${viewType} already registered`);
132
}
133
134
const disposables = new DisposableStore();
135
136
disposables.add(this._customEditorService.registerCustomEditorCapabilities(viewType, {
137
supportsMultipleEditorsPerDocument
138
}));
139
140
disposables.add(this._webviewWorkbenchService.registerResolver({
141
canResolve: (webviewInput) => {
142
return webviewInput instanceof CustomEditorInput && webviewInput.viewType === viewType;
143
},
144
resolveWebview: async (webviewInput: CustomEditorInput, cancellation: CancellationToken) => {
145
const handle = generateUuid();
146
const resource = webviewInput.resource;
147
148
webviewInput.webview.origin = this._webviewOriginStore.getOrigin(viewType, extension.id);
149
150
this.mainThreadWebviewPanels.addWebviewInput(handle, webviewInput, { serializeBuffersForPostMessage });
151
webviewInput.webview.options = options;
152
webviewInput.webview.extension = extension;
153
154
// If there's an old resource this was a move and we must resolve the backup at the same time as the webview
155
// This is because the backup must be ready upon model creation, and the input resolve method comes after
156
let backupId = webviewInput.backupId;
157
if (webviewInput.oldResource && !webviewInput.backupId) {
158
const backup = this._editorRenameBackups.get(webviewInput.oldResource.toString());
159
backupId = backup?.backupId;
160
this._editorRenameBackups.delete(webviewInput.oldResource.toString());
161
}
162
163
let modelRef: IReference<ICustomEditorModel>;
164
try {
165
modelRef = await this.getOrCreateCustomEditorModel(modelType, resource, viewType, { backupId }, cancellation);
166
} catch (error) {
167
onUnexpectedError(error);
168
webviewInput.webview.setHtml(this.mainThreadWebview.getWebviewResolvedFailedContent(viewType));
169
return;
170
}
171
172
if (cancellation.isCancellationRequested) {
173
modelRef.dispose();
174
return;
175
}
176
177
const disposeSub = webviewInput.webview.onDidDispose(() => {
178
disposeSub.dispose();
179
inputDisposeSub.dispose();
180
181
// If the model is still dirty, make sure we have time to save it
182
if (modelRef.object.isDirty()) {
183
const sub = modelRef.object.onDidChangeDirty(() => {
184
if (!modelRef.object.isDirty()) {
185
sub.dispose();
186
modelRef.dispose();
187
}
188
});
189
return;
190
}
191
192
modelRef.dispose();
193
});
194
195
// Also listen for when the input is disposed (e.g., during SaveAs when the webview is transferred to a new editor).
196
// In this case, webview.onDidDispose won't fire because the webview is reused.
197
const inputDisposeSub = webviewInput.onWillDispose(() => {
198
inputDisposeSub.dispose();
199
disposeSub.dispose();
200
modelRef.dispose();
201
});
202
203
if (capabilities.supportsMove) {
204
webviewInput.onMove(async (newResource: URI) => {
205
const oldModel = modelRef;
206
modelRef = await this.getOrCreateCustomEditorModel(modelType, newResource, viewType, {}, CancellationToken.None);
207
this._proxyCustomEditors.$onMoveCustomEditor(handle, newResource, viewType);
208
oldModel.dispose();
209
});
210
}
211
212
try {
213
const actualResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(resource) : resource;
214
await this._proxyCustomEditors.$resolveCustomEditor(actualResource, handle, viewType, {
215
title: webviewInput.getTitle(),
216
contentOptions: webviewInput.webview.contentOptions,
217
options: webviewInput.webview.options,
218
active: webviewInput === this._editorService.activeEditor,
219
}, editorGroupToColumn(this._editorGroupService, webviewInput.group || 0), cancellation);
220
} catch (error) {
221
onUnexpectedError(error);
222
webviewInput.webview.setHtml(this.mainThreadWebview.getWebviewResolvedFailedContent(viewType));
223
modelRef.dispose();
224
return;
225
}
226
}
227
}));
228
229
this._editorProviders.set(viewType, disposables);
230
}
231
232
public $unregisterEditorProvider(viewType: string): void {
233
if (!this._editorProviders.has(viewType)) {
234
throw new Error(`No provider for ${viewType} registered`);
235
}
236
237
this._editorProviders.deleteAndDispose(viewType);
238
239
this._customEditorService.models.disposeAllModelsForView(viewType);
240
}
241
242
private async getOrCreateCustomEditorModel(
243
modelType: CustomEditorModelType,
244
resource: URI,
245
viewType: string,
246
options: { backupId?: string },
247
cancellation: CancellationToken,
248
): Promise<IReference<ICustomEditorModel>> {
249
const existingModel = this._customEditorService.models.tryRetain(resource, viewType);
250
if (existingModel) {
251
return existingModel;
252
}
253
254
switch (modelType) {
255
case CustomEditorModelType.Text:
256
{
257
const model = CustomTextEditorModel.create(this._instantiationService, viewType, resource);
258
return this._customEditorService.models.add(resource, viewType, model);
259
}
260
case CustomEditorModelType.Custom:
261
{
262
const model = MainThreadCustomEditorModel.create(this._instantiationService, this._proxyCustomEditors, viewType, resource, options, () => {
263
return Array.from(this.mainThreadWebviewPanels.webviewInputs)
264
.filter(editor => editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) as CustomEditorInput[];
265
}, cancellation);
266
return this._customEditorService.models.add(resource, viewType, model);
267
}
268
}
269
}
270
271
public async $onDidEdit(resourceComponents: UriComponents, viewType: string, editId: number, label: string | undefined): Promise<void> {
272
const model = await this.getCustomEditorModel(resourceComponents, viewType);
273
model.pushEdit(editId, label);
274
}
275
276
public async $onContentChange(resourceComponents: UriComponents, viewType: string): Promise<void> {
277
const model = await this.getCustomEditorModel(resourceComponents, viewType);
278
model.changeContent();
279
}
280
281
private async getCustomEditorModel(resourceComponents: UriComponents, viewType: string) {
282
const resource = URI.revive(resourceComponents);
283
const model = await this._customEditorService.models.get(resource, viewType);
284
if (!model || !(model instanceof MainThreadCustomEditorModel)) {
285
throw new Error('Could not find model for webview editor');
286
}
287
return model;
288
}
289
290
//#region Working Copy
291
private async onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent) {
292
if (e.operation !== FileOperation.MOVE) {
293
return;
294
}
295
e.waitUntil((async () => {
296
const models = [];
297
for (const file of e.files) {
298
if (file.source) {
299
models.push(...(await this._customEditorService.models.getAllModels(file.source)));
300
}
301
}
302
for (const model of models) {
303
if (model instanceof MainThreadCustomEditorModel && model.isDirty()) {
304
const workingCopy = await model.backup(CancellationToken.None);
305
if (workingCopy.meta) {
306
// This cast is safe because we do an instanceof check above and a custom document backup data is always returned
307
this._editorRenameBackups.set(model.editorResource.toString(), workingCopy.meta as CustomDocumentBackupData);
308
}
309
}
310
}
311
})());
312
}
313
//#endregion
314
}
315
316
namespace HotExitState {
317
export const enum Type {
318
Allowed,
319
NotAllowed,
320
Pending,
321
}
322
323
export const Allowed = Object.freeze({ type: Type.Allowed } as const);
324
export const NotAllowed = Object.freeze({ type: Type.NotAllowed } as const);
325
326
export class Pending {
327
readonly type = Type.Pending;
328
329
constructor(
330
public readonly operation: CancelablePromise<string>,
331
) { }
332
}
333
334
export type State = typeof Allowed | typeof NotAllowed | Pending;
335
}
336
337
338
class MainThreadCustomEditorModel extends ResourceWorkingCopy implements ICustomEditorModel {
339
340
private _fromBackup: boolean = false;
341
private _hotExitState: HotExitState.State = HotExitState.Allowed;
342
private _backupId: string | undefined;
343
344
private _currentEditIndex: number = -1;
345
private _savePoint: number = -1;
346
private readonly _edits: Array<number> = [];
347
private _isDirtyFromContentChange: boolean;
348
349
private _ongoingSave?: CancelablePromise<void>;
350
351
// TODO@mjbvz consider to enable a `typeId` that is specific for custom
352
// editors. Using a distinct `typeId` allows the working copy to have
353
// any resource (including file based resources) even if other working
354
// copies exist with the same resource.
355
//
356
// IMPORTANT: changing the `typeId` has an impact on backups for this
357
// working copy. Any value that is not the empty string will be used
358
// as seed to the backup. Only change the `typeId` if you have implemented
359
// a fallback solution to resolve any existing backups that do not have
360
// this seed.
361
readonly typeId = NO_TYPE_ID;
362
363
public static async create(
364
instantiationService: IInstantiationService,
365
proxy: extHostProtocol.ExtHostCustomEditorsShape,
366
viewType: string,
367
resource: URI,
368
options: { backupId?: string },
369
getEditors: () => CustomEditorInput[],
370
cancellation: CancellationToken,
371
): Promise<MainThreadCustomEditorModel> {
372
const editors = getEditors();
373
let untitledDocumentData: VSBuffer | undefined;
374
if (editors.length !== 0) {
375
untitledDocumentData = editors[0].untitledDocumentData;
376
}
377
const { editable } = await proxy.$createCustomDocument(resource, viewType, options.backupId, untitledDocumentData, cancellation);
378
return instantiationService.createInstance(MainThreadCustomEditorModel, proxy, viewType, resource, !!options.backupId, editable, !!untitledDocumentData, getEditors);
379
}
380
381
constructor(
382
private readonly _proxy: extHostProtocol.ExtHostCustomEditorsShape,
383
private readonly _viewType: string,
384
private readonly _editorResource: URI,
385
fromBackup: boolean,
386
private readonly _editable: boolean,
387
startDirty: boolean,
388
private readonly _getEditors: () => CustomEditorInput[],
389
@IFileDialogService private readonly _fileDialogService: IFileDialogService,
390
@IFileService fileService: IFileService,
391
@ILabelService private readonly _labelService: ILabelService,
392
@IUndoRedoService private readonly _undoService: IUndoRedoService,
393
@IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService,
394
@IWorkingCopyService workingCopyService: IWorkingCopyService,
395
@IPathService private readonly _pathService: IPathService,
396
@IExtensionService extensionService: IExtensionService,
397
) {
398
super(MainThreadCustomEditorModel.toWorkingCopyResource(_viewType, _editorResource), fileService);
399
400
this._fromBackup = fromBackup;
401
402
// Normally means we're re-opening an untitled file (set this before registering the working copy
403
// so that dirty state is correct when first queried).
404
this._isDirtyFromContentChange = startDirty;
405
406
if (_editable) {
407
this._register(workingCopyService.registerWorkingCopy(this));
408
409
this._register(extensionService.onWillStop(e => {
410
e.veto(true, localize('vetoExtHostRestart', "An extension provided editor for '{0}' is still open that would close otherwise.", this.name));
411
}));
412
}
413
}
414
415
get editorResource() {
416
return this._editorResource;
417
}
418
419
override dispose() {
420
if (this._editable) {
421
this._undoService.removeElements(this._editorResource);
422
}
423
424
this._proxy.$disposeCustomDocument(this._editorResource, this._viewType);
425
426
super.dispose();
427
}
428
429
//#region IWorkingCopy
430
431
// Make sure each custom editor has a unique resource for backup and edits
432
private static toWorkingCopyResource(viewType: string, resource: URI) {
433
const authority = viewType.replace(/[^a-z0-9\-_]/gi, '-');
434
const path = `/${multibyteAwareBtoa(resource.with({ query: null, fragment: null }).toString(true))}`;
435
return URI.from({
436
scheme: Schemas.vscodeCustomEditor,
437
authority: authority,
438
path: path,
439
query: JSON.stringify(resource.toJSON()),
440
});
441
}
442
443
public get name() {
444
return basename(this._labelService.getUriLabel(this._editorResource));
445
}
446
447
public get capabilities(): WorkingCopyCapabilities {
448
return this.isUntitled() ? WorkingCopyCapabilities.Untitled : WorkingCopyCapabilities.None;
449
}
450
451
public isDirty(): boolean {
452
if (this._isDirtyFromContentChange) {
453
return true;
454
}
455
if (this._edits.length > 0) {
456
return this._savePoint !== this._currentEditIndex;
457
}
458
return this._fromBackup;
459
}
460
461
private isUntitled() {
462
return this._editorResource.scheme === Schemas.untitled;
463
}
464
465
private readonly _onDidChangeDirty: Emitter<void> = this._register(new Emitter<void>());
466
readonly onDidChangeDirty: Event<void> = this._onDidChangeDirty.event;
467
468
private readonly _onDidChangeContent: Emitter<void> = this._register(new Emitter<void>());
469
readonly onDidChangeContent: Event<void> = this._onDidChangeContent.event;
470
471
private readonly _onDidSave: Emitter<IWorkingCopySaveEvent> = this._register(new Emitter<IWorkingCopySaveEvent>());
472
readonly onDidSave: Event<IWorkingCopySaveEvent> = this._onDidSave.event;
473
474
readonly onDidChangeReadonly = Event.None;
475
476
//#endregion
477
478
public isReadonly(): boolean {
479
return !this._editable;
480
}
481
482
public get viewType() {
483
return this._viewType;
484
}
485
486
public get backupId() {
487
return this._backupId;
488
}
489
490
public pushEdit(editId: number, label: string | undefined) {
491
if (!this._editable) {
492
throw new Error('Document is not editable');
493
}
494
495
this.change(() => {
496
this.spliceEdits(editId);
497
this._currentEditIndex = this._edits.length - 1;
498
});
499
500
this._undoService.pushElement({
501
type: UndoRedoElementType.Resource,
502
resource: this._editorResource,
503
label: label ?? localize('defaultEditLabel', "Edit"),
504
code: 'undoredo.customEditorEdit',
505
undo: () => this.undo(),
506
redo: () => this.redo(),
507
});
508
}
509
510
public changeContent() {
511
this.change(() => {
512
this._isDirtyFromContentChange = true;
513
});
514
}
515
516
private async undo(): Promise<void> {
517
if (!this._editable) {
518
return;
519
}
520
521
if (this._currentEditIndex < 0) {
522
// nothing to undo
523
return;
524
}
525
526
const undoneEdit = this._edits[this._currentEditIndex];
527
this.change(() => {
528
--this._currentEditIndex;
529
});
530
await this._proxy.$undo(this._editorResource, this.viewType, undoneEdit, this.isDirty());
531
}
532
533
private async redo(): Promise<void> {
534
if (!this._editable) {
535
return;
536
}
537
538
if (this._currentEditIndex >= this._edits.length - 1) {
539
// nothing to redo
540
return;
541
}
542
543
const redoneEdit = this._edits[this._currentEditIndex + 1];
544
this.change(() => {
545
++this._currentEditIndex;
546
});
547
await this._proxy.$redo(this._editorResource, this.viewType, redoneEdit, this.isDirty());
548
}
549
550
private spliceEdits(editToInsert?: number) {
551
const start = this._currentEditIndex + 1;
552
const toRemove = this._edits.length - this._currentEditIndex;
553
554
const removedEdits = typeof editToInsert === 'number'
555
? this._edits.splice(start, toRemove, editToInsert)
556
: this._edits.splice(start, toRemove);
557
558
if (removedEdits.length) {
559
this._proxy.$disposeEdits(this._editorResource, this._viewType, removedEdits);
560
}
561
}
562
563
private change(makeEdit: () => void): void {
564
const wasDirty = this.isDirty();
565
makeEdit();
566
this._onDidChangeContent.fire();
567
568
if (this.isDirty() !== wasDirty) {
569
this._onDidChangeDirty.fire();
570
}
571
}
572
573
public async revert(options?: IRevertOptions) {
574
if (!this._editable) {
575
return;
576
}
577
578
if (this._currentEditIndex === this._savePoint && !this._isDirtyFromContentChange && !this._fromBackup) {
579
return;
580
}
581
582
if (!options?.soft) {
583
this._proxy.$revert(this._editorResource, this.viewType, CancellationToken.None);
584
}
585
586
this.change(() => {
587
this._isDirtyFromContentChange = false;
588
this._fromBackup = false;
589
this._currentEditIndex = this._savePoint;
590
this.spliceEdits();
591
});
592
}
593
594
public async save(options?: ISaveOptions): Promise<boolean> {
595
const result = !!await this.saveCustomEditor(options);
596
597
// Emit Save Event
598
if (result) {
599
this._onDidSave.fire({ reason: options?.reason, source: options?.source });
600
}
601
602
return result;
603
}
604
605
public async saveCustomEditor(options?: ISaveOptions): Promise<URI | undefined> {
606
if (!this._editable) {
607
return undefined;
608
}
609
610
if (this.isUntitled()) {
611
const targetUri = await this.suggestUntitledSavePath(options);
612
if (!targetUri) {
613
return undefined;
614
}
615
616
await this.saveCustomEditorAs(this._editorResource, targetUri, options);
617
return targetUri;
618
}
619
620
const savePromise = createCancelablePromise(token => this._proxy.$onSave(this._editorResource, this.viewType, token));
621
this._ongoingSave?.cancel();
622
this._ongoingSave = savePromise;
623
624
try {
625
await savePromise;
626
627
if (this._ongoingSave === savePromise) { // Make sure we are still doing the same save
628
this.change(() => {
629
this._isDirtyFromContentChange = false;
630
this._savePoint = this._currentEditIndex;
631
this._fromBackup = false;
632
});
633
}
634
} finally {
635
if (this._ongoingSave === savePromise) { // Make sure we are still doing the same save
636
this._ongoingSave = undefined;
637
}
638
}
639
640
return this._editorResource;
641
}
642
643
private suggestUntitledSavePath(options: ISaveOptions | undefined): Promise<URI | undefined> {
644
if (!this.isUntitled()) {
645
throw new Error('Resource is not untitled');
646
}
647
648
const remoteAuthority = this._environmentService.remoteAuthority;
649
const localResource = toLocalResource(this._editorResource, remoteAuthority, this._pathService.defaultUriScheme);
650
651
return this._fileDialogService.pickFileToSave(localResource, options?.availableFileSystems);
652
}
653
654
public async saveCustomEditorAs(resource: URI, targetResource: URI, _options?: ISaveOptions): Promise<boolean> {
655
if (this._editable) {
656
// TODO: handle cancellation
657
await createCancelablePromise(token => this._proxy.$onSaveAs(this._editorResource, this.viewType, targetResource, token));
658
this.change(() => {
659
this._isDirtyFromContentChange = false;
660
this._savePoint = this._currentEditIndex;
661
this._fromBackup = false;
662
});
663
return true;
664
} else {
665
// Since the editor is readonly, just copy the file over
666
await this.fileService.copy(resource, targetResource, false /* overwrite */);
667
return true;
668
}
669
}
670
671
public get canHotExit() { return typeof this._backupId === 'string' && this._hotExitState.type === HotExitState.Type.Allowed; }
672
673
public async backup(token: CancellationToken): Promise<IWorkingCopyBackup> {
674
const editors = this._getEditors();
675
if (!editors.length) {
676
throw new Error('No editors found for resource, cannot back up');
677
}
678
const primaryEditor = editors[0];
679
680
const backupMeta: CustomDocumentBackupData = {
681
viewType: this.viewType,
682
editorResource: this._editorResource,
683
customTitle: primaryEditor.getWebviewTitle(),
684
iconPath: primaryEditor.iconPath,
685
backupId: '',
686
extension: primaryEditor.extension ? {
687
id: primaryEditor.extension.id.value,
688
location: primaryEditor.extension.location!,
689
} : undefined,
690
webview: {
691
origin: primaryEditor.webview.origin,
692
options: primaryEditor.webview.options,
693
state: primaryEditor.webview.state,
694
}
695
};
696
697
const backupData: IWorkingCopyBackup = {
698
meta: backupMeta
699
};
700
701
if (!this._editable) {
702
return backupData;
703
}
704
705
if (this._hotExitState.type === HotExitState.Type.Pending) {
706
this._hotExitState.operation.cancel();
707
}
708
709
const pendingState = new HotExitState.Pending(
710
createCancelablePromise(token =>
711
this._proxy.$backup(this._editorResource.toJSON(), this.viewType, token)));
712
this._hotExitState = pendingState;
713
714
token.onCancellationRequested(() => {
715
pendingState.operation.cancel();
716
});
717
718
let errorMessage = '';
719
try {
720
const backupId = await pendingState.operation;
721
// Make sure state has not changed in the meantime
722
if (this._hotExitState === pendingState) {
723
this._hotExitState = HotExitState.Allowed;
724
backupData.meta!.backupId = backupId;
725
this._backupId = backupId;
726
}
727
} catch (e) {
728
if (isCancellationError(e)) {
729
// This is expected
730
throw e;
731
}
732
733
// Otherwise it could be a real error. Make sure state has not changed in the meantime.
734
if (this._hotExitState === pendingState) {
735
this._hotExitState = HotExitState.NotAllowed;
736
}
737
if (e.message) {
738
errorMessage = e.message;
739
}
740
}
741
742
if (this._hotExitState === HotExitState.Allowed) {
743
return backupData;
744
}
745
746
throw new Error(`Cannot backup in this state: ${errorMessage}`);
747
}
748
}
749
750