Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/api/browser/mainThreadCustomEditors.ts
3296 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
180
// If the model is still dirty, make sure we have time to save it
181
if (modelRef.object.isDirty()) {
182
const sub = modelRef.object.onDidChangeDirty(() => {
183
if (!modelRef.object.isDirty()) {
184
sub.dispose();
185
modelRef.dispose();
186
}
187
});
188
return;
189
}
190
191
modelRef.dispose();
192
});
193
194
if (capabilities.supportsMove) {
195
webviewInput.onMove(async (newResource: URI) => {
196
const oldModel = modelRef;
197
modelRef = await this.getOrCreateCustomEditorModel(modelType, newResource, viewType, {}, CancellationToken.None);
198
this._proxyCustomEditors.$onMoveCustomEditor(handle, newResource, viewType);
199
oldModel.dispose();
200
});
201
}
202
203
try {
204
const actualResource = modelType === CustomEditorModelType.Text ? this._uriIdentityService.asCanonicalUri(resource) : resource;
205
await this._proxyCustomEditors.$resolveCustomEditor(actualResource, handle, viewType, {
206
title: webviewInput.getTitle(),
207
contentOptions: webviewInput.webview.contentOptions,
208
options: webviewInput.webview.options,
209
active: webviewInput === this._editorService.activeEditor,
210
}, editorGroupToColumn(this._editorGroupService, webviewInput.group || 0), cancellation);
211
} catch (error) {
212
onUnexpectedError(error);
213
webviewInput.webview.setHtml(this.mainThreadWebview.getWebviewResolvedFailedContent(viewType));
214
modelRef.dispose();
215
return;
216
}
217
}
218
}));
219
220
this._editorProviders.set(viewType, disposables);
221
}
222
223
public $unregisterEditorProvider(viewType: string): void {
224
if (!this._editorProviders.has(viewType)) {
225
throw new Error(`No provider for ${viewType} registered`);
226
}
227
228
this._editorProviders.deleteAndDispose(viewType);
229
230
this._customEditorService.models.disposeAllModelsForView(viewType);
231
}
232
233
private async getOrCreateCustomEditorModel(
234
modelType: CustomEditorModelType,
235
resource: URI,
236
viewType: string,
237
options: { backupId?: string },
238
cancellation: CancellationToken,
239
): Promise<IReference<ICustomEditorModel>> {
240
const existingModel = this._customEditorService.models.tryRetain(resource, viewType);
241
if (existingModel) {
242
return existingModel;
243
}
244
245
switch (modelType) {
246
case CustomEditorModelType.Text:
247
{
248
const model = CustomTextEditorModel.create(this._instantiationService, viewType, resource);
249
return this._customEditorService.models.add(resource, viewType, model);
250
}
251
case CustomEditorModelType.Custom:
252
{
253
const model = MainThreadCustomEditorModel.create(this._instantiationService, this._proxyCustomEditors, viewType, resource, options, () => {
254
return Array.from(this.mainThreadWebviewPanels.webviewInputs)
255
.filter(editor => editor instanceof CustomEditorInput && isEqual(editor.resource, resource)) as CustomEditorInput[];
256
}, cancellation);
257
return this._customEditorService.models.add(resource, viewType, model);
258
}
259
}
260
}
261
262
public async $onDidEdit(resourceComponents: UriComponents, viewType: string, editId: number, label: string | undefined): Promise<void> {
263
const model = await this.getCustomEditorModel(resourceComponents, viewType);
264
model.pushEdit(editId, label);
265
}
266
267
public async $onContentChange(resourceComponents: UriComponents, viewType: string): Promise<void> {
268
const model = await this.getCustomEditorModel(resourceComponents, viewType);
269
model.changeContent();
270
}
271
272
private async getCustomEditorModel(resourceComponents: UriComponents, viewType: string) {
273
const resource = URI.revive(resourceComponents);
274
const model = await this._customEditorService.models.get(resource, viewType);
275
if (!model || !(model instanceof MainThreadCustomEditorModel)) {
276
throw new Error('Could not find model for webview editor');
277
}
278
return model;
279
}
280
281
//#region Working Copy
282
private async onWillRunWorkingCopyFileOperation(e: WorkingCopyFileEvent) {
283
if (e.operation !== FileOperation.MOVE) {
284
return;
285
}
286
e.waitUntil((async () => {
287
const models = [];
288
for (const file of e.files) {
289
if (file.source) {
290
models.push(...(await this._customEditorService.models.getAllModels(file.source)));
291
}
292
}
293
for (const model of models) {
294
if (model instanceof MainThreadCustomEditorModel && model.isDirty()) {
295
const workingCopy = await model.backup(CancellationToken.None);
296
if (workingCopy.meta) {
297
// This cast is safe because we do an instanceof check above and a custom document backup data is always returned
298
this._editorRenameBackups.set(model.editorResource.toString(), workingCopy.meta as CustomDocumentBackupData);
299
}
300
}
301
}
302
})());
303
}
304
//#endregion
305
}
306
307
namespace HotExitState {
308
export const enum Type {
309
Allowed,
310
NotAllowed,
311
Pending,
312
}
313
314
export const Allowed = Object.freeze({ type: Type.Allowed } as const);
315
export const NotAllowed = Object.freeze({ type: Type.NotAllowed } as const);
316
317
export class Pending {
318
readonly type = Type.Pending;
319
320
constructor(
321
public readonly operation: CancelablePromise<string>,
322
) { }
323
}
324
325
export type State = typeof Allowed | typeof NotAllowed | Pending;
326
}
327
328
329
class MainThreadCustomEditorModel extends ResourceWorkingCopy implements ICustomEditorModel {
330
331
private _fromBackup: boolean = false;
332
private _hotExitState: HotExitState.State = HotExitState.Allowed;
333
private _backupId: string | undefined;
334
335
private _currentEditIndex: number = -1;
336
private _savePoint: number = -1;
337
private readonly _edits: Array<number> = [];
338
private _isDirtyFromContentChange = false;
339
340
private _ongoingSave?: CancelablePromise<void>;
341
342
// TODO@mjbvz consider to enable a `typeId` that is specific for custom
343
// editors. Using a distinct `typeId` allows the working copy to have
344
// any resource (including file based resources) even if other working
345
// copies exist with the same resource.
346
//
347
// IMPORTANT: changing the `typeId` has an impact on backups for this
348
// working copy. Any value that is not the empty string will be used
349
// as seed to the backup. Only change the `typeId` if you have implemented
350
// a fallback solution to resolve any existing backups that do not have
351
// this seed.
352
readonly typeId = NO_TYPE_ID;
353
354
public static async create(
355
instantiationService: IInstantiationService,
356
proxy: extHostProtocol.ExtHostCustomEditorsShape,
357
viewType: string,
358
resource: URI,
359
options: { backupId?: string },
360
getEditors: () => CustomEditorInput[],
361
cancellation: CancellationToken,
362
): Promise<MainThreadCustomEditorModel> {
363
const editors = getEditors();
364
let untitledDocumentData: VSBuffer | undefined;
365
if (editors.length !== 0) {
366
untitledDocumentData = editors[0].untitledDocumentData;
367
}
368
const { editable } = await proxy.$createCustomDocument(resource, viewType, options.backupId, untitledDocumentData, cancellation);
369
return instantiationService.createInstance(MainThreadCustomEditorModel, proxy, viewType, resource, !!options.backupId, editable, !!untitledDocumentData, getEditors);
370
}
371
372
constructor(
373
private readonly _proxy: extHostProtocol.ExtHostCustomEditorsShape,
374
private readonly _viewType: string,
375
private readonly _editorResource: URI,
376
fromBackup: boolean,
377
private readonly _editable: boolean,
378
startDirty: boolean,
379
private readonly _getEditors: () => CustomEditorInput[],
380
@IFileDialogService private readonly _fileDialogService: IFileDialogService,
381
@IFileService fileService: IFileService,
382
@ILabelService private readonly _labelService: ILabelService,
383
@IUndoRedoService private readonly _undoService: IUndoRedoService,
384
@IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService,
385
@IWorkingCopyService workingCopyService: IWorkingCopyService,
386
@IPathService private readonly _pathService: IPathService,
387
@IExtensionService extensionService: IExtensionService,
388
) {
389
super(MainThreadCustomEditorModel.toWorkingCopyResource(_viewType, _editorResource), fileService);
390
391
this._fromBackup = fromBackup;
392
393
if (_editable) {
394
this._register(workingCopyService.registerWorkingCopy(this));
395
396
this._register(extensionService.onWillStop(e => {
397
e.veto(true, localize('vetoExtHostRestart', "An extension provided editor for '{0}' is still open that would close otherwise.", this.name));
398
}));
399
}
400
401
// Normally means we're re-opening an untitled file
402
if (startDirty) {
403
this._isDirtyFromContentChange = true;
404
}
405
}
406
407
get editorResource() {
408
return this._editorResource;
409
}
410
411
override dispose() {
412
if (this._editable) {
413
this._undoService.removeElements(this._editorResource);
414
}
415
416
this._proxy.$disposeCustomDocument(this._editorResource, this._viewType);
417
418
super.dispose();
419
}
420
421
//#region IWorkingCopy
422
423
// Make sure each custom editor has a unique resource for backup and edits
424
private static toWorkingCopyResource(viewType: string, resource: URI) {
425
const authority = viewType.replace(/[^a-z0-9\-_]/gi, '-');
426
const path = `/${multibyteAwareBtoa(resource.with({ query: null, fragment: null }).toString(true))}`;
427
return URI.from({
428
scheme: Schemas.vscodeCustomEditor,
429
authority: authority,
430
path: path,
431
query: JSON.stringify(resource.toJSON()),
432
});
433
}
434
435
public get name() {
436
return basename(this._labelService.getUriLabel(this._editorResource));
437
}
438
439
public get capabilities(): WorkingCopyCapabilities {
440
return this.isUntitled() ? WorkingCopyCapabilities.Untitled : WorkingCopyCapabilities.None;
441
}
442
443
public isDirty(): boolean {
444
if (this._isDirtyFromContentChange) {
445
return true;
446
}
447
if (this._edits.length > 0) {
448
return this._savePoint !== this._currentEditIndex;
449
}
450
return this._fromBackup;
451
}
452
453
private isUntitled() {
454
return this._editorResource.scheme === Schemas.untitled;
455
}
456
457
private readonly _onDidChangeDirty: Emitter<void> = this._register(new Emitter<void>());
458
readonly onDidChangeDirty: Event<void> = this._onDidChangeDirty.event;
459
460
private readonly _onDidChangeContent: Emitter<void> = this._register(new Emitter<void>());
461
readonly onDidChangeContent: Event<void> = this._onDidChangeContent.event;
462
463
private readonly _onDidSave: Emitter<IWorkingCopySaveEvent> = this._register(new Emitter<IWorkingCopySaveEvent>());
464
readonly onDidSave: Event<IWorkingCopySaveEvent> = this._onDidSave.event;
465
466
readonly onDidChangeReadonly = Event.None;
467
468
//#endregion
469
470
public isReadonly(): boolean {
471
return !this._editable;
472
}
473
474
public get viewType() {
475
return this._viewType;
476
}
477
478
public get backupId() {
479
return this._backupId;
480
}
481
482
public pushEdit(editId: number, label: string | undefined) {
483
if (!this._editable) {
484
throw new Error('Document is not editable');
485
}
486
487
this.change(() => {
488
this.spliceEdits(editId);
489
this._currentEditIndex = this._edits.length - 1;
490
});
491
492
this._undoService.pushElement({
493
type: UndoRedoElementType.Resource,
494
resource: this._editorResource,
495
label: label ?? localize('defaultEditLabel', "Edit"),
496
code: 'undoredo.customEditorEdit',
497
undo: () => this.undo(),
498
redo: () => this.redo(),
499
});
500
}
501
502
public changeContent() {
503
this.change(() => {
504
this._isDirtyFromContentChange = true;
505
});
506
}
507
508
private async undo(): Promise<void> {
509
if (!this._editable) {
510
return;
511
}
512
513
if (this._currentEditIndex < 0) {
514
// nothing to undo
515
return;
516
}
517
518
const undoneEdit = this._edits[this._currentEditIndex];
519
this.change(() => {
520
--this._currentEditIndex;
521
});
522
await this._proxy.$undo(this._editorResource, this.viewType, undoneEdit, this.isDirty());
523
}
524
525
private async redo(): Promise<void> {
526
if (!this._editable) {
527
return;
528
}
529
530
if (this._currentEditIndex >= this._edits.length - 1) {
531
// nothing to redo
532
return;
533
}
534
535
const redoneEdit = this._edits[this._currentEditIndex + 1];
536
this.change(() => {
537
++this._currentEditIndex;
538
});
539
await this._proxy.$redo(this._editorResource, this.viewType, redoneEdit, this.isDirty());
540
}
541
542
private spliceEdits(editToInsert?: number) {
543
const start = this._currentEditIndex + 1;
544
const toRemove = this._edits.length - this._currentEditIndex;
545
546
const removedEdits = typeof editToInsert === 'number'
547
? this._edits.splice(start, toRemove, editToInsert)
548
: this._edits.splice(start, toRemove);
549
550
if (removedEdits.length) {
551
this._proxy.$disposeEdits(this._editorResource, this._viewType, removedEdits);
552
}
553
}
554
555
private change(makeEdit: () => void): void {
556
const wasDirty = this.isDirty();
557
makeEdit();
558
this._onDidChangeContent.fire();
559
560
if (this.isDirty() !== wasDirty) {
561
this._onDidChangeDirty.fire();
562
}
563
}
564
565
public async revert(options?: IRevertOptions) {
566
if (!this._editable) {
567
return;
568
}
569
570
if (this._currentEditIndex === this._savePoint && !this._isDirtyFromContentChange && !this._fromBackup) {
571
return;
572
}
573
574
if (!options?.soft) {
575
this._proxy.$revert(this._editorResource, this.viewType, CancellationToken.None);
576
}
577
578
this.change(() => {
579
this._isDirtyFromContentChange = false;
580
this._fromBackup = false;
581
this._currentEditIndex = this._savePoint;
582
this.spliceEdits();
583
});
584
}
585
586
public async save(options?: ISaveOptions): Promise<boolean> {
587
const result = !!await this.saveCustomEditor(options);
588
589
// Emit Save Event
590
if (result) {
591
this._onDidSave.fire({ reason: options?.reason, source: options?.source });
592
}
593
594
return result;
595
}
596
597
public async saveCustomEditor(options?: ISaveOptions): Promise<URI | undefined> {
598
if (!this._editable) {
599
return undefined;
600
}
601
602
if (this.isUntitled()) {
603
const targetUri = await this.suggestUntitledSavePath(options);
604
if (!targetUri) {
605
return undefined;
606
}
607
608
await this.saveCustomEditorAs(this._editorResource, targetUri, options);
609
return targetUri;
610
}
611
612
const savePromise = createCancelablePromise(token => this._proxy.$onSave(this._editorResource, this.viewType, token));
613
this._ongoingSave?.cancel();
614
this._ongoingSave = savePromise;
615
616
try {
617
await savePromise;
618
619
if (this._ongoingSave === savePromise) { // Make sure we are still doing the same save
620
this.change(() => {
621
this._isDirtyFromContentChange = false;
622
this._savePoint = this._currentEditIndex;
623
this._fromBackup = false;
624
});
625
}
626
} finally {
627
if (this._ongoingSave === savePromise) { // Make sure we are still doing the same save
628
this._ongoingSave = undefined;
629
}
630
}
631
632
return this._editorResource;
633
}
634
635
private suggestUntitledSavePath(options: ISaveOptions | undefined): Promise<URI | undefined> {
636
if (!this.isUntitled()) {
637
throw new Error('Resource is not untitled');
638
}
639
640
const remoteAuthority = this._environmentService.remoteAuthority;
641
const localResource = toLocalResource(this._editorResource, remoteAuthority, this._pathService.defaultUriScheme);
642
643
return this._fileDialogService.pickFileToSave(localResource, options?.availableFileSystems);
644
}
645
646
public async saveCustomEditorAs(resource: URI, targetResource: URI, _options?: ISaveOptions): Promise<boolean> {
647
if (this._editable) {
648
// TODO: handle cancellation
649
await createCancelablePromise(token => this._proxy.$onSaveAs(this._editorResource, this.viewType, targetResource, token));
650
this.change(() => {
651
this._savePoint = this._currentEditIndex;
652
});
653
return true;
654
} else {
655
// Since the editor is readonly, just copy the file over
656
await this.fileService.copy(resource, targetResource, false /* overwrite */);
657
return true;
658
}
659
}
660
661
public get canHotExit() { return typeof this._backupId === 'string' && this._hotExitState.type === HotExitState.Type.Allowed; }
662
663
public async backup(token: CancellationToken): Promise<IWorkingCopyBackup> {
664
const editors = this._getEditors();
665
if (!editors.length) {
666
throw new Error('No editors found for resource, cannot back up');
667
}
668
const primaryEditor = editors[0];
669
670
const backupMeta: CustomDocumentBackupData = {
671
viewType: this.viewType,
672
editorResource: this._editorResource,
673
backupId: '',
674
extension: primaryEditor.extension ? {
675
id: primaryEditor.extension.id.value,
676
location: primaryEditor.extension.location!,
677
} : undefined,
678
webview: {
679
origin: primaryEditor.webview.origin,
680
options: primaryEditor.webview.options,
681
state: primaryEditor.webview.state,
682
}
683
};
684
685
const backupData: IWorkingCopyBackup = {
686
meta: backupMeta
687
};
688
689
if (!this._editable) {
690
return backupData;
691
}
692
693
if (this._hotExitState.type === HotExitState.Type.Pending) {
694
this._hotExitState.operation.cancel();
695
}
696
697
const pendingState = new HotExitState.Pending(
698
createCancelablePromise(token =>
699
this._proxy.$backup(this._editorResource.toJSON(), this.viewType, token)));
700
this._hotExitState = pendingState;
701
702
token.onCancellationRequested(() => {
703
pendingState.operation.cancel();
704
});
705
706
let errorMessage = '';
707
try {
708
const backupId = await pendingState.operation;
709
// Make sure state has not changed in the meantime
710
if (this._hotExitState === pendingState) {
711
this._hotExitState = HotExitState.Allowed;
712
backupData.meta!.backupId = backupId;
713
this._backupId = backupId;
714
}
715
} catch (e) {
716
if (isCancellationError(e)) {
717
// This is expected
718
throw e;
719
}
720
721
// Otherwise it could be a real error. Make sure state has not changed in the meantime.
722
if (this._hotExitState === pendingState) {
723
this._hotExitState = HotExitState.NotAllowed;
724
}
725
if (e.message) {
726
errorMessage = e.message;
727
}
728
}
729
730
if (this._hotExitState === HotExitState.Allowed) {
731
return backupData;
732
}
733
734
throw new Error(`Cannot backup in this state: ${errorMessage}`);
735
}
736
}
737
738