Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.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 { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
7
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from '../../../common/contributions.js';
8
import { Registry } from '../../../../platform/registry/common/platform.js';
9
import { ILifecycleService, LifecyclePhase, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js';
10
import { Action2, IAction2Options, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
11
import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
12
import { localize, localize2 } from '../../../../nls.js';
13
import { IEditSessionsStorageService, Change, ChangeType, Folder, EditSession, FileType, EDIT_SESSION_SYNC_CATEGORY, EDIT_SESSIONS_CONTAINER_ID, EditSessionSchemaVersion, IEditSessionsLogService, EDIT_SESSIONS_VIEW_ICON, EDIT_SESSIONS_TITLE, EDIT_SESSIONS_SHOW_VIEW, EDIT_SESSIONS_DATA_VIEW_ID, decodeEditSessionFileContent, hashedEditSessionId, editSessionsLogId, EDIT_SESSIONS_PENDING } from '../common/editSessions.js';
14
import { ISCMRepository, ISCMService } from '../../scm/common/scm.js';
15
import { IFileService } from '../../../../platform/files/common/files.js';
16
import { IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';
17
import { URI } from '../../../../base/common/uri.js';
18
import { basename, joinPath, relativePath } from '../../../../base/common/resources.js';
19
import { encodeBase64 } from '../../../../base/common/buffer.js';
20
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
21
import { IProgress, IProgressService, IProgressStep, ProgressLocation } from '../../../../platform/progress/common/progress.js';
22
import { EditSessionsWorkbenchService } from './editSessionsStorageService.js';
23
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
24
import { UserDataSyncErrorCode, UserDataSyncStoreError } from '../../../../platform/userDataSync/common/userDataSync.js';
25
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
26
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
27
import { getFileNamesMessage, IDialogService, IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
28
import { IProductService } from '../../../../platform/product/common/productService.js';
29
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
30
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
31
import { workbenchConfigurationNodeBase } from '../../../common/configuration.js';
32
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
33
import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
34
import { ExtensionsRegistry } from '../../../services/extensions/common/extensionsRegistry.js';
35
import { ContextKeyExpr, ContextKeyExpression, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
36
import { ICommandService } from '../../../../platform/commands/common/commands.js';
37
import { getVirtualWorkspaceLocation } from '../../../../platform/workspace/common/virtualWorkspace.js';
38
import { Schemas } from '../../../../base/common/network.js';
39
import { IsWebContext } from '../../../../platform/contextkey/common/contextkeys.js';
40
import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';
41
import { EditSessionsLogService } from '../common/editSessionsLogService.js';
42
import { IViewContainersRegistry, Extensions as ViewExtensions, ViewContainerLocation } from '../../../common/views.js';
43
import { IViewsService } from '../../../services/views/common/viewsService.js';
44
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
45
import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';
46
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
47
import { EditSessionsDataViews } from './editSessionsViews.js';
48
import { EditSessionsFileSystemProvider } from './editSessionsFileSystemProvider.js';
49
import { isNative, isWeb } from '../../../../base/common/platform.js';
50
import { VirtualWorkspaceContext, WorkspaceFolderCountContext } from '../../../common/contextkeys.js';
51
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
52
import { equals } from '../../../../base/common/objects.js';
53
import { EditSessionIdentityMatch, IEditSessionIdentityService } from '../../../../platform/workspace/common/editSessions.js';
54
import { ThemeIcon } from '../../../../base/common/themables.js';
55
import { IOutputService } from '../../../services/output/common/output.js';
56
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
57
import { IActivityService, NumberBadge } from '../../../services/activity/common/activity.js';
58
import { IEditorService } from '../../../services/editor/common/editorService.js';
59
import { ILocalizedString } from '../../../../platform/action/common/action.js';
60
import { Codicon } from '../../../../base/common/codicons.js';
61
import { CancellationError } from '../../../../base/common/errors.js';
62
import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js';
63
import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js';
64
import { WorkspaceStateSynchroniser } from '../common/workspaceStateSync.js';
65
import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js';
66
import { IRequestService } from '../../../../platform/request/common/request.js';
67
import { EditSessionsStoreClient } from '../common/editSessionsStorageClient.js';
68
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
69
import { IWorkspaceIdentityService } from '../../../services/workspaces/common/workspaceIdentityService.js';
70
import { hashAsync } from '../../../../base/common/hash.js';
71
import { ResourceSet } from '../../../../base/common/map.js';
72
73
registerSingleton(IEditSessionsLogService, EditSessionsLogService, InstantiationType.Delayed);
74
registerSingleton(IEditSessionsStorageService, EditSessionsWorkbenchService, InstantiationType.Delayed);
75
76
77
const continueWorkingOnCommand: IAction2Options = {
78
id: '_workbench.editSessions.actions.continueEditSession',
79
title: localize2('continue working on', 'Continue Working On...'),
80
precondition: WorkspaceFolderCountContext.notEqualsTo('0'),
81
f1: true
82
};
83
const openLocalFolderCommand: IAction2Options = {
84
id: '_workbench.editSessions.actions.continueEditSession.openLocalFolder',
85
title: localize2('continue edit session in local folder', 'Open In Local Folder'),
86
category: EDIT_SESSION_SYNC_CATEGORY,
87
precondition: ContextKeyExpr.and(IsWebContext.toNegated(), VirtualWorkspaceContext)
88
};
89
const showOutputChannelCommand: IAction2Options = {
90
id: 'workbench.editSessions.actions.showOutputChannel',
91
title: localize2('show log', "Show Log"),
92
category: EDIT_SESSION_SYNC_CATEGORY
93
};
94
const installAdditionalContinueOnOptionsCommand = {
95
id: 'workbench.action.continueOn.extensions',
96
title: localize('continueOn.installAdditional', 'Install additional development environment options'),
97
};
98
registerAction2(class extends Action2 {
99
constructor() {
100
super({ ...installAdditionalContinueOnOptionsCommand, f1: false });
101
}
102
103
async run(accessor: ServicesAccessor): Promise<void> {
104
return accessor.get(IExtensionsWorkbenchService).openSearch('@tag:continueOn');
105
}
106
});
107
108
const resumeProgressOptionsTitle = `[${localize('resuming working changes window', 'Resuming working changes...')}](command:${showOutputChannelCommand.id})`;
109
const resumeProgressOptions = {
110
location: ProgressLocation.Window,
111
type: 'syncing',
112
};
113
const queryParamName = 'editSessionId';
114
115
const useEditSessionsWithContinueOn = 'workbench.editSessions.continueOn';
116
export class EditSessionsContribution extends Disposable implements IWorkbenchContribution {
117
118
private continueEditSessionOptions: ContinueEditSessionItem[] = [];
119
120
private readonly shouldShowViewsContext: IContextKey<boolean>;
121
private readonly pendingEditSessionsContext: IContextKey<boolean>;
122
123
private static APPLICATION_LAUNCHED_VIA_CONTINUE_ON_STORAGE_KEY = 'applicationLaunchedViaContinueOn';
124
private readonly accountsMenuBadgeDisposable = this._register(new MutableDisposable());
125
126
private registeredCommands = new Set<string>();
127
128
private workspaceStateSynchronizer: WorkspaceStateSynchroniser | undefined;
129
private editSessionsStorageClient: EditSessionsStoreClient | undefined;
130
131
constructor(
132
@IEditSessionsStorageService private readonly editSessionsStorageService: IEditSessionsStorageService,
133
@IFileService private readonly fileService: IFileService,
134
@IProgressService private readonly progressService: IProgressService,
135
@IOpenerService private readonly openerService: IOpenerService,
136
@ITelemetryService private readonly telemetryService: ITelemetryService,
137
@ISCMService private readonly scmService: ISCMService,
138
@INotificationService private readonly notificationService: INotificationService,
139
@IDialogService private readonly dialogService: IDialogService,
140
@IEditSessionsLogService private readonly logService: IEditSessionsLogService,
141
@IEnvironmentService private readonly environmentService: IEnvironmentService,
142
@IInstantiationService private readonly instantiationService: IInstantiationService,
143
@IProductService private readonly productService: IProductService,
144
@IConfigurationService private configurationService: IConfigurationService,
145
@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,
146
@IEditSessionIdentityService private readonly editSessionIdentityService: IEditSessionIdentityService,
147
@IQuickInputService private readonly quickInputService: IQuickInputService,
148
@ICommandService private commandService: ICommandService,
149
@IContextKeyService private readonly contextKeyService: IContextKeyService,
150
@IFileDialogService private readonly fileDialogService: IFileDialogService,
151
@ILifecycleService private readonly lifecycleService: ILifecycleService,
152
@IStorageService private readonly storageService: IStorageService,
153
@IActivityService private readonly activityService: IActivityService,
154
@IEditorService private readonly editorService: IEditorService,
155
@IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService,
156
@IExtensionService private readonly extensionService: IExtensionService,
157
@IRequestService private readonly requestService: IRequestService,
158
@IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService,
159
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
160
@IWorkspaceIdentityService private readonly workspaceIdentityService: IWorkspaceIdentityService,
161
) {
162
super();
163
164
this.shouldShowViewsContext = EDIT_SESSIONS_SHOW_VIEW.bindTo(this.contextKeyService);
165
this.pendingEditSessionsContext = EDIT_SESSIONS_PENDING.bindTo(this.contextKeyService);
166
this.pendingEditSessionsContext.set(false);
167
168
if (!this.productService['editSessions.store']?.url) {
169
return;
170
}
171
172
this.editSessionsStorageClient = new EditSessionsStoreClient(URI.parse(this.productService['editSessions.store'].url), this.productService, this.requestService, this.logService, this.environmentService, this.fileService, this.storageService);
173
this.editSessionsStorageService.storeClient = this.editSessionsStorageClient;
174
this.workspaceStateSynchronizer = new WorkspaceStateSynchroniser(this.userDataProfilesService.defaultProfile, undefined, this.editSessionsStorageClient, this.logService, this.fileService, this.environmentService, this.telemetryService, this.configurationService, this.storageService, this.uriIdentityService, this.workspaceIdentityService, this.editSessionsStorageService);
175
176
this.autoResumeEditSession();
177
178
this.registerActions();
179
this.registerViews();
180
this.registerContributedEditSessionOptions();
181
182
this._register(this.fileService.registerProvider(EditSessionsFileSystemProvider.SCHEMA, new EditSessionsFileSystemProvider(this.editSessionsStorageService)));
183
this.lifecycleService.onWillShutdown((e) => {
184
if (e.reason !== ShutdownReason.RELOAD && this.editSessionsStorageService.isSignedIn && this.configurationService.getValue('workbench.experimental.cloudChanges.autoStore') === 'onShutdown' && !isWeb) {
185
e.join(this.autoStoreEditSession(), { id: 'autoStoreWorkingChanges', label: localize('autoStoreWorkingChanges', 'Storing current working changes...') });
186
}
187
});
188
this._register(this.editSessionsStorageService.onDidSignIn(() => this.updateAccountsMenuBadge()));
189
this._register(this.editSessionsStorageService.onDidSignOut(() => this.updateAccountsMenuBadge()));
190
}
191
192
private async autoResumeEditSession() {
193
const shouldAutoResumeOnReload = this.configurationService.getValue('workbench.cloudChanges.autoResume') === 'onReload';
194
195
if (this.environmentService.editSessionId !== undefined) {
196
this.logService.info(`Resuming cloud changes, reason: found editSessionId ${this.environmentService.editSessionId} in environment service...`);
197
await this.progressService.withProgress(resumeProgressOptions, async (progress) => await this.resumeEditSession(this.environmentService.editSessionId, undefined, undefined, undefined, progress).finally(() => this.environmentService.editSessionId = undefined));
198
} else if (shouldAutoResumeOnReload && this.editSessionsStorageService.isSignedIn) {
199
this.logService.info('Resuming cloud changes, reason: cloud changes enabled...');
200
// Attempt to resume edit session based on edit workspace identifier
201
// Note: at this point if the user is not signed into edit sessions,
202
// we don't want them to be prompted to sign in and should just return early
203
await this.progressService.withProgress(resumeProgressOptions, async (progress) => await this.resumeEditSession(undefined, true, undefined, undefined, progress));
204
} else if (shouldAutoResumeOnReload) {
205
// The application has previously launched via a protocol URL Continue On flow
206
const hasApplicationLaunchedFromContinueOnFlow = this.storageService.getBoolean(EditSessionsContribution.APPLICATION_LAUNCHED_VIA_CONTINUE_ON_STORAGE_KEY, StorageScope.APPLICATION, false);
207
this.logService.info(`Prompting to enable cloud changes, has application previously launched from Continue On flow: ${hasApplicationLaunchedFromContinueOnFlow}`);
208
209
const handlePendingEditSessions = () => {
210
// display a badge in the accounts menu but do not prompt the user to sign in again
211
this.logService.info('Showing badge to enable cloud changes in accounts menu...');
212
this.updateAccountsMenuBadge();
213
this.pendingEditSessionsContext.set(true);
214
// attempt a resume if we are in a pending state and the user just signed in
215
const disposable = this.editSessionsStorageService.onDidSignIn(async () => {
216
disposable.dispose();
217
this.logService.info('Showing badge to enable cloud changes in accounts menu succeeded, resuming cloud changes...');
218
await this.progressService.withProgress(resumeProgressOptions, async (progress) => await this.resumeEditSession(undefined, true, undefined, undefined, progress));
219
this.storageService.remove(EditSessionsContribution.APPLICATION_LAUNCHED_VIA_CONTINUE_ON_STORAGE_KEY, StorageScope.APPLICATION);
220
this.environmentService.continueOn = undefined;
221
});
222
};
223
224
if ((this.environmentService.continueOn !== undefined) &&
225
!this.editSessionsStorageService.isSignedIn &&
226
// and user has not yet been prompted to sign in on this machine
227
hasApplicationLaunchedFromContinueOnFlow === false
228
) {
229
// store the fact that we prompted the user
230
this.storageService.store(EditSessionsContribution.APPLICATION_LAUNCHED_VIA_CONTINUE_ON_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE);
231
this.logService.info('Prompting to enable cloud changes...');
232
await this.editSessionsStorageService.initialize('read');
233
if (this.editSessionsStorageService.isSignedIn) {
234
this.logService.info('Prompting to enable cloud changes succeeded, resuming cloud changes...');
235
await this.progressService.withProgress(resumeProgressOptions, async (progress) => await this.resumeEditSession(undefined, true, undefined, undefined, progress));
236
} else {
237
handlePendingEditSessions();
238
}
239
} else if (!this.editSessionsStorageService.isSignedIn &&
240
// and user has been prompted to sign in on this machine
241
hasApplicationLaunchedFromContinueOnFlow === true
242
) {
243
handlePendingEditSessions();
244
}
245
} else {
246
this.logService.debug('Auto resuming cloud changes disabled.');
247
}
248
}
249
250
private updateAccountsMenuBadge() {
251
if (this.editSessionsStorageService.isSignedIn) {
252
return this.accountsMenuBadgeDisposable.clear();
253
}
254
255
const badge = new NumberBadge(1, () => localize('check for pending cloud changes', 'Check for pending cloud changes'));
256
this.accountsMenuBadgeDisposable.value = this.activityService.showAccountsActivity({ badge });
257
}
258
259
private async autoStoreEditSession() {
260
const cancellationTokenSource = new CancellationTokenSource();
261
await this.progressService.withProgress({
262
location: ProgressLocation.Window,
263
type: 'syncing',
264
title: localize('store working changes', 'Storing working changes...')
265
}, async () => this.storeEditSession(false, cancellationTokenSource.token), () => {
266
cancellationTokenSource.cancel();
267
cancellationTokenSource.dispose();
268
});
269
}
270
271
private registerViews() {
272
const container = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).registerViewContainer(
273
{
274
id: EDIT_SESSIONS_CONTAINER_ID,
275
title: EDIT_SESSIONS_TITLE,
276
ctorDescriptor: new SyncDescriptor(
277
ViewPaneContainer,
278
[EDIT_SESSIONS_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]
279
),
280
icon: EDIT_SESSIONS_VIEW_ICON,
281
hideIfEmpty: true
282
}, ViewContainerLocation.Sidebar, { doNotRegisterOpenCommand: true }
283
);
284
this._register(this.instantiationService.createInstance(EditSessionsDataViews, container));
285
}
286
287
private registerActions() {
288
this.registerContinueEditSessionAction();
289
290
this.registerResumeLatestEditSessionAction();
291
this.registerStoreLatestEditSessionAction();
292
293
this.registerContinueInLocalFolderAction();
294
295
this.registerShowEditSessionViewAction();
296
this.registerShowEditSessionOutputChannelAction();
297
}
298
299
private registerShowEditSessionOutputChannelAction() {
300
this._register(registerAction2(class ShowEditSessionOutput extends Action2 {
301
constructor() {
302
super(showOutputChannelCommand);
303
}
304
305
run(accessor: ServicesAccessor, ...args: any[]) {
306
const outputChannel = accessor.get(IOutputService);
307
void outputChannel.showChannel(editSessionsLogId);
308
}
309
}));
310
}
311
312
private registerShowEditSessionViewAction() {
313
const that = this;
314
this._register(registerAction2(class ShowEditSessionView extends Action2 {
315
constructor() {
316
super({
317
id: 'workbench.editSessions.actions.showEditSessions',
318
title: localize2('show cloud changes', 'Show Cloud Changes'),
319
category: EDIT_SESSION_SYNC_CATEGORY,
320
f1: true
321
});
322
}
323
324
async run(accessor: ServicesAccessor) {
325
that.shouldShowViewsContext.set(true);
326
const viewsService = accessor.get(IViewsService);
327
await viewsService.openView(EDIT_SESSIONS_DATA_VIEW_ID);
328
}
329
}));
330
}
331
332
private registerContinueEditSessionAction() {
333
const that = this;
334
this._register(registerAction2(class ContinueEditSessionAction extends Action2 {
335
constructor() {
336
super(continueWorkingOnCommand);
337
}
338
339
async run(accessor: ServicesAccessor, workspaceUri: URI | undefined, destination: string | undefined): Promise<void> {
340
type ContinueOnEventOutcome = { outcome: string; hashedId?: string };
341
type ContinueOnClassificationOutcome = {
342
owner: 'joyceerhl'; comment: 'Reporting the outcome of invoking the Continue On action.';
343
outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The outcome of invoking continue edit session.' };
344
hashedId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The hash of the stored edit session id, for correlating success of stores and resumes.' };
345
};
346
347
// First ask the user to pick a destination, if necessary
348
let uri: URI | 'noDestinationUri' | undefined = workspaceUri;
349
if (!destination && !uri) {
350
destination = await that.pickContinueEditSessionDestination();
351
if (!destination) {
352
that.telemetryService.publicLog2<ContinueOnEventOutcome, ContinueOnClassificationOutcome>('continueOn.editSessions.pick.outcome', { outcome: 'noSelection' });
353
return;
354
}
355
}
356
357
// Determine if we need to store an edit session, asking for edit session auth if necessary
358
const shouldStoreEditSession = await that.shouldContinueOnWithEditSession();
359
360
// Run the store action to get back a ref
361
let ref: string | undefined;
362
if (shouldStoreEditSession) {
363
type ContinueWithEditSessionEvent = {};
364
type ContinueWithEditSessionClassification = {
365
owner: 'joyceerhl'; comment: 'Reporting when storing an edit session as part of the Continue On flow.';
366
};
367
that.telemetryService.publicLog2<ContinueWithEditSessionEvent, ContinueWithEditSessionClassification>('continueOn.editSessions.store');
368
369
const cancellationTokenSource = new CancellationTokenSource();
370
try {
371
ref = await that.progressService.withProgress({
372
location: ProgressLocation.Notification,
373
cancellable: true,
374
type: 'syncing',
375
title: localize('store your working changes', 'Storing your working changes...')
376
}, async () => {
377
const ref = await that.storeEditSession(false, cancellationTokenSource.token);
378
if (ref !== undefined) {
379
that.telemetryService.publicLog2<ContinueOnEventOutcome, ContinueOnClassificationOutcome>('continueOn.editSessions.store.outcome', { outcome: 'storeSucceeded', hashedId: hashedEditSessionId(ref) });
380
} else {
381
that.telemetryService.publicLog2<ContinueOnEventOutcome, ContinueOnClassificationOutcome>('continueOn.editSessions.store.outcome', { outcome: 'storeSkipped' });
382
}
383
return ref;
384
}, () => {
385
cancellationTokenSource.cancel();
386
cancellationTokenSource.dispose();
387
that.telemetryService.publicLog2<ContinueOnEventOutcome, ContinueOnClassificationOutcome>('continueOn.editSessions.store.outcome', { outcome: 'storeCancelledByUser' });
388
});
389
} catch (ex) {
390
that.telemetryService.publicLog2<ContinueOnEventOutcome, ContinueOnClassificationOutcome>('continueOn.editSessions.store.outcome', { outcome: 'storeFailed' });
391
throw ex;
392
}
393
}
394
395
// Append the ref to the URI
396
uri = destination ? await that.resolveDestination(destination) : uri;
397
if (uri === undefined) {
398
return;
399
}
400
401
if (ref !== undefined && uri !== 'noDestinationUri') {
402
const encodedRef = encodeURIComponent(ref);
403
uri = uri.with({
404
query: uri.query.length > 0 ? (uri.query + `&${queryParamName}=${encodedRef}&continueOn=1`) : `${queryParamName}=${encodedRef}&continueOn=1`
405
});
406
407
// Open the URI
408
that.logService.info(`Opening ${uri.toString()}`);
409
await that.openerService.open(uri, { openExternal: true });
410
} else if ((!shouldStoreEditSession || ref === undefined) && uri !== 'noDestinationUri') {
411
// Open the URI without an edit session ref
412
that.logService.info(`Opening ${uri.toString()}`);
413
await that.openerService.open(uri, { openExternal: true });
414
} else if (ref === undefined && shouldStoreEditSession) {
415
that.logService.warn(`Failed to store working changes when invoking ${continueWorkingOnCommand.id}.`);
416
}
417
}
418
}));
419
}
420
421
private registerResumeLatestEditSessionAction(): void {
422
const that = this;
423
this._register(registerAction2(class ResumeLatestEditSessionAction extends Action2 {
424
constructor() {
425
super({
426
id: 'workbench.editSessions.actions.resumeLatest',
427
title: localize2('resume latest cloud changes', 'Resume Latest Changes from Cloud'),
428
category: EDIT_SESSION_SYNC_CATEGORY,
429
f1: true,
430
});
431
}
432
433
async run(accessor: ServicesAccessor, editSessionId?: string, forceApplyUnrelatedChange?: boolean): Promise<void> {
434
await that.progressService.withProgress({ ...resumeProgressOptions, title: resumeProgressOptionsTitle }, async () => await that.resumeEditSession(editSessionId, undefined, forceApplyUnrelatedChange));
435
}
436
}));
437
this._register(registerAction2(class ResumeLatestEditSessionAction extends Action2 {
438
constructor() {
439
super({
440
id: 'workbench.editSessions.actions.resumeFromSerializedPayload',
441
title: localize2('resume cloud changes', 'Resume Changes from Serialized Data'),
442
category: 'Developer',
443
f1: true,
444
});
445
}
446
447
async run(accessor: ServicesAccessor, editSessionId?: string): Promise<void> {
448
const data = await that.quickInputService.input({ prompt: 'Enter serialized data' });
449
if (data) {
450
that.editSessionsStorageService.lastReadResources.set('editSessions', { content: data, ref: '' });
451
}
452
await that.progressService.withProgress({ ...resumeProgressOptions, title: resumeProgressOptionsTitle }, async () => await that.resumeEditSession(editSessionId, undefined, undefined, undefined, undefined, data));
453
}
454
}));
455
}
456
457
private registerStoreLatestEditSessionAction(): void {
458
const that = this;
459
this._register(registerAction2(class StoreLatestEditSessionAction extends Action2 {
460
constructor() {
461
super({
462
id: 'workbench.editSessions.actions.storeCurrent',
463
title: localize2('store working changes in cloud', 'Store Working Changes in Cloud'),
464
category: EDIT_SESSION_SYNC_CATEGORY,
465
f1: true,
466
});
467
}
468
469
async run(accessor: ServicesAccessor): Promise<void> {
470
const cancellationTokenSource = new CancellationTokenSource();
471
await that.progressService.withProgress({
472
location: ProgressLocation.Notification,
473
title: localize('storing working changes', 'Storing working changes...')
474
}, async () => {
475
type StoreEvent = {};
476
type StoreClassification = {
477
owner: 'joyceerhl'; comment: 'Reporting when the store edit session action is invoked.';
478
};
479
that.telemetryService.publicLog2<StoreEvent, StoreClassification>('editSessions.store');
480
481
await that.storeEditSession(true, cancellationTokenSource.token);
482
}, () => {
483
cancellationTokenSource.cancel();
484
cancellationTokenSource.dispose();
485
});
486
}
487
}));
488
}
489
490
async resumeEditSession(ref?: string, silent?: boolean, forceApplyUnrelatedChange?: boolean, applyPartialMatch?: boolean, progress?: IProgress<IProgressStep>, serializedData?: string): Promise<void> {
491
// Wait for the remote environment to become available, if any
492
await this.remoteAgentService.getEnvironment();
493
494
// Edit sessions are not currently supported in empty workspaces
495
// https://github.com/microsoft/vscode/issues/159220
496
if (this.contextService.getWorkbenchState() === WorkbenchState.EMPTY) {
497
return;
498
}
499
500
this.logService.info(ref !== undefined ? `Resuming changes from cloud with ref ${ref}...` : 'Checking for pending cloud changes...');
501
502
if (silent && !(await this.editSessionsStorageService.initialize('read', true))) {
503
return;
504
}
505
506
type ResumeEvent = { outcome: string; hashedId?: string };
507
type ResumeClassification = {
508
owner: 'joyceerhl'; comment: 'Reporting when an edit session is resumed from an edit session identifier.';
509
outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The outcome of resuming the edit session.' };
510
hashedId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The hash of the stored edit session id, for correlating success of stores and resumes.' };
511
};
512
this.telemetryService.publicLog2<ResumeEvent, ResumeClassification>('editSessions.resume');
513
514
performance.mark('code/willResumeEditSessionFromIdentifier');
515
516
progress?.report({ message: localize('checkingForWorkingChanges', 'Checking for pending cloud changes...') });
517
const data = serializedData ? { content: serializedData, ref: '' } : await this.editSessionsStorageService.read('editSessions', ref);
518
if (!data) {
519
if (ref === undefined && !silent) {
520
this.notificationService.info(localize('no cloud changes', 'There are no changes to resume from the cloud.'));
521
} else if (ref !== undefined) {
522
this.notificationService.warn(localize('no cloud changes for ref', 'Could not resume changes from the cloud for ID {0}.', ref));
523
}
524
this.logService.info(ref !== undefined ? `Aborting resuming changes from cloud as no edit session content is available to be applied from ref ${ref}.` : `Aborting resuming edit session as no edit session content is available to be applied`);
525
return;
526
}
527
528
progress?.report({ message: resumeProgressOptionsTitle });
529
const editSession = JSON.parse(data.content);
530
ref = data.ref;
531
532
if (editSession.version > EditSessionSchemaVersion) {
533
this.notificationService.error(localize('client too old', "Please upgrade to a newer version of {0} to resume your working changes from the cloud.", this.productService.nameLong));
534
this.telemetryService.publicLog2<ResumeEvent, ResumeClassification>('editSessions.resume.outcome', { hashedId: hashedEditSessionId(ref), outcome: 'clientUpdateNeeded' });
535
return;
536
}
537
538
try {
539
const { changes, conflictingChanges } = await this.generateChanges(editSession, ref, forceApplyUnrelatedChange, applyPartialMatch);
540
if (changes.length === 0) {
541
return;
542
}
543
544
// TODO@joyceerhl Provide the option to diff files which would be overwritten by edit session contents
545
if (conflictingChanges.length > 0) {
546
// Allow to show edit sessions
547
548
const { confirmed } = await this.dialogService.confirm({
549
type: Severity.Warning,
550
message: conflictingChanges.length > 1 ?
551
localize('resume edit session warning many', 'Resuming your working changes from the cloud will overwrite the following {0} files. Do you want to proceed?', conflictingChanges.length) :
552
localize('resume edit session warning 1', 'Resuming your working changes from the cloud will overwrite {0}. Do you want to proceed?', basename(conflictingChanges[0].uri)),
553
detail: conflictingChanges.length > 1 ? getFileNamesMessage(conflictingChanges.map((c) => c.uri)) : undefined
554
});
555
556
if (!confirmed) {
557
return;
558
}
559
}
560
561
for (const { uri, type, contents } of changes) {
562
if (type === ChangeType.Addition) {
563
await this.fileService.writeFile(uri, decodeEditSessionFileContent(editSession.version, contents!));
564
} else if (type === ChangeType.Deletion && await this.fileService.exists(uri)) {
565
await this.fileService.del(uri);
566
}
567
}
568
569
await this.workspaceStateSynchronizer?.apply();
570
571
this.logService.info(`Deleting edit session with ref ${ref} after successfully applying it to current workspace...`);
572
await this.editSessionsStorageService.delete('editSessions', ref);
573
this.logService.info(`Deleted edit session with ref ${ref}.`);
574
575
this.telemetryService.publicLog2<ResumeEvent, ResumeClassification>('editSessions.resume.outcome', { hashedId: hashedEditSessionId(ref), outcome: 'resumeSucceeded' });
576
} catch (ex) {
577
this.logService.error('Failed to resume edit session, reason: ', (ex as Error).toString());
578
this.notificationService.error(localize('resume failed', "Failed to resume your working changes from the cloud."));
579
}
580
581
performance.mark('code/didResumeEditSessionFromIdentifier');
582
}
583
584
private async generateChanges(editSession: EditSession, ref: string, forceApplyUnrelatedChange = false, applyPartialMatch = false) {
585
const changes: ({ uri: URI; type: ChangeType; contents: string | undefined })[] = [];
586
const conflictingChanges = [];
587
const workspaceFolders = this.contextService.getWorkspace().folders;
588
const cancellationTokenSource = new CancellationTokenSource();
589
590
for (const folder of editSession.folders) {
591
let folderRoot: IWorkspaceFolder | undefined;
592
593
if (folder.canonicalIdentity) {
594
// Look for an edit session identifier that we can use
595
for (const f of workspaceFolders) {
596
const identity = await this.editSessionIdentityService.getEditSessionIdentifier(f, cancellationTokenSource.token);
597
this.logService.info(`Matching identity ${identity} against edit session folder identity ${folder.canonicalIdentity}...`);
598
599
if (equals(identity, folder.canonicalIdentity) || forceApplyUnrelatedChange) {
600
folderRoot = f;
601
break;
602
}
603
604
if (identity !== undefined) {
605
const match = await this.editSessionIdentityService.provideEditSessionIdentityMatch(f, identity, folder.canonicalIdentity, cancellationTokenSource.token);
606
if (match === EditSessionIdentityMatch.Complete) {
607
folderRoot = f;
608
break;
609
} else if (match === EditSessionIdentityMatch.Partial &&
610
this.configurationService.getValue('workbench.experimental.cloudChanges.partialMatches.enabled') === true
611
) {
612
if (!applyPartialMatch) {
613
// Surface partially matching edit session
614
this.notificationService.prompt(
615
Severity.Info,
616
localize('editSessionPartialMatch', 'You have pending working changes in the cloud for this workspace. Would you like to resume them?'),
617
[{ label: localize('resume', 'Resume'), run: () => this.resumeEditSession(ref, false, undefined, true) }]
618
);
619
} else {
620
folderRoot = f;
621
break;
622
}
623
}
624
}
625
}
626
} else {
627
folderRoot = workspaceFolders.find((f) => f.name === folder.name);
628
}
629
630
if (!folderRoot) {
631
this.logService.info(`Skipping applying ${folder.workingChanges.length} changes from edit session with ref ${ref} as no matching workspace folder was found.`);
632
return { changes: [], conflictingChanges: [], contributedStateHandlers: [] };
633
}
634
635
const localChanges = new Set<string>();
636
for (const repository of this.scmService.repositories) {
637
if (repository.provider.rootUri !== undefined &&
638
this.contextService.getWorkspaceFolder(repository.provider.rootUri)?.name === folder.name
639
) {
640
const repositoryChanges = this.getChangedResources(repository);
641
repositoryChanges.forEach((change) => localChanges.add(change.toString()));
642
}
643
}
644
645
for (const change of folder.workingChanges) {
646
const uri = joinPath(folderRoot.uri, change.relativeFilePath);
647
648
changes.push({ uri, type: change.type, contents: change.contents });
649
if (await this.willChangeLocalContents(localChanges, uri, change)) {
650
conflictingChanges.push({ uri, type: change.type, contents: change.contents });
651
}
652
}
653
}
654
655
return { changes, conflictingChanges };
656
}
657
658
private async willChangeLocalContents(localChanges: Set<string>, uriWithIncomingChanges: URI, incomingChange: Change) {
659
if (!localChanges.has(uriWithIncomingChanges.toString())) {
660
return false;
661
}
662
663
const { contents, type } = incomingChange;
664
665
switch (type) {
666
case (ChangeType.Addition): {
667
const [originalContents, incomingContents] = await Promise.all([
668
hashAsync(contents),
669
hashAsync(encodeBase64((await this.fileService.readFile(uriWithIncomingChanges)).value))
670
]);
671
return originalContents !== incomingContents;
672
}
673
case (ChangeType.Deletion): {
674
return await this.fileService.exists(uriWithIncomingChanges);
675
}
676
default:
677
throw new Error('Unhandled change type.');
678
}
679
}
680
681
async storeEditSession(fromStoreCommand: boolean, cancellationToken: CancellationToken): Promise<string | undefined> {
682
const folders: Folder[] = [];
683
let editSessionSize = 0;
684
let hasEdits = false;
685
686
// Save all saveable editors before building edit session contents
687
await this.editorService.saveAll();
688
689
// Do a first pass over all repositories to ensure that the edit session identity is created for each.
690
// This may change the working changes that need to be stored later
691
const createdEditSessionIdentities = new ResourceSet();
692
for (const repository of this.scmService.repositories) {
693
const changedResources = this.getChangedResources(repository);
694
if (!changedResources.size) {
695
continue;
696
}
697
for (const uri of changedResources) {
698
const workspaceFolder = this.contextService.getWorkspaceFolder(uri);
699
if (!workspaceFolder || createdEditSessionIdentities.has(uri)) {
700
continue;
701
}
702
createdEditSessionIdentities.add(uri);
703
await this.editSessionIdentityService.onWillCreateEditSessionIdentity(workspaceFolder, cancellationToken);
704
}
705
}
706
707
for (const repository of this.scmService.repositories) {
708
// Look through all resource groups and compute which files were added/modified/deleted
709
const trackedUris = this.getChangedResources(repository); // A URI might appear in more than one resource group
710
711
const workingChanges: Change[] = [];
712
713
const { rootUri } = repository.provider;
714
const workspaceFolder = rootUri ? this.contextService.getWorkspaceFolder(rootUri) : undefined;
715
let name = workspaceFolder?.name;
716
717
for (const uri of trackedUris) {
718
const workspaceFolder = this.contextService.getWorkspaceFolder(uri);
719
if (!workspaceFolder) {
720
this.logService.info(`Skipping working change ${uri.toString()} as no associated workspace folder was found.`);
721
722
continue;
723
}
724
725
name = name ?? workspaceFolder.name;
726
const relativeFilePath = relativePath(workspaceFolder.uri, uri) ?? uri.path;
727
728
// Only deal with file contents for now
729
try {
730
if (!(await this.fileService.stat(uri)).isFile) {
731
continue;
732
}
733
} catch { }
734
735
hasEdits = true;
736
737
738
if (await this.fileService.exists(uri)) {
739
const contents = encodeBase64((await this.fileService.readFile(uri)).value);
740
editSessionSize += contents.length;
741
if (editSessionSize > this.editSessionsStorageService.SIZE_LIMIT) {
742
this.notificationService.error(localize('payload too large', 'Your working changes exceed the size limit and cannot be stored.'));
743
return undefined;
744
}
745
746
workingChanges.push({ type: ChangeType.Addition, fileType: FileType.File, contents: contents, relativeFilePath: relativeFilePath });
747
} else {
748
// Assume it's a deletion
749
workingChanges.push({ type: ChangeType.Deletion, fileType: FileType.File, contents: undefined, relativeFilePath: relativeFilePath });
750
}
751
}
752
753
let canonicalIdentity = undefined;
754
if (workspaceFolder !== null && workspaceFolder !== undefined) {
755
canonicalIdentity = await this.editSessionIdentityService.getEditSessionIdentifier(workspaceFolder, cancellationToken);
756
}
757
758
// TODO@joyceerhl debt: don't store working changes as a child of the folder
759
folders.push({ workingChanges, name: name ?? '', canonicalIdentity: canonicalIdentity ?? undefined, absoluteUri: workspaceFolder?.uri.toString() });
760
}
761
762
// Store contributed workspace state
763
await this.workspaceStateSynchronizer?.sync();
764
765
if (!hasEdits) {
766
this.logService.info('Skipped storing working changes in the cloud as there are no edits to store.');
767
if (fromStoreCommand) {
768
this.notificationService.info(localize('no working changes to store', 'Skipped storing working changes in the cloud as there are no edits to store.'));
769
}
770
return undefined;
771
}
772
773
const data: EditSession = { folders, version: 2, workspaceStateId: this.editSessionsStorageService.lastWrittenResources.get('workspaceState')?.ref };
774
775
try {
776
this.logService.info(`Storing edit session...`);
777
const ref = await this.editSessionsStorageService.write('editSessions', data);
778
this.logService.info(`Stored edit session with ref ${ref}.`);
779
return ref;
780
} catch (ex) {
781
this.logService.error(`Failed to store edit session, reason: `, (ex as Error).toString());
782
783
type UploadFailedEvent = { reason: string };
784
type UploadFailedClassification = {
785
owner: 'joyceerhl'; comment: 'Reporting when Continue On server request fails.';
786
reason?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason that the server request failed.' };
787
};
788
789
if (ex instanceof UserDataSyncStoreError) {
790
switch (ex.code) {
791
case UserDataSyncErrorCode.TooLarge:
792
// Uploading a payload can fail due to server size limits
793
this.telemetryService.publicLog2<UploadFailedEvent, UploadFailedClassification>('editSessions.upload.failed', { reason: 'TooLarge' });
794
this.notificationService.error(localize('payload too large', 'Your working changes exceed the size limit and cannot be stored.'));
795
break;
796
default:
797
this.telemetryService.publicLog2<UploadFailedEvent, UploadFailedClassification>('editSessions.upload.failed', { reason: 'unknown' });
798
this.notificationService.error(localize('payload failed', 'Your working changes cannot be stored.'));
799
break;
800
}
801
}
802
}
803
804
return undefined;
805
}
806
807
private getChangedResources(repository: ISCMRepository) {
808
return repository.provider.groups.reduce((resources, resourceGroups) => {
809
resourceGroups.resources.forEach((resource) => resources.add(resource.sourceUri));
810
return resources;
811
}, new Set<URI>()); // A URI might appear in more than one resource group
812
}
813
814
private hasEditSession() {
815
for (const repository of this.scmService.repositories) {
816
if (this.getChangedResources(repository).size > 0) {
817
return true;
818
}
819
}
820
return false;
821
}
822
823
private async shouldContinueOnWithEditSession(): Promise<boolean> {
824
type EditSessionsAuthCheckEvent = { outcome: string };
825
type EditSessionsAuthCheckClassification = {
826
owner: 'joyceerhl'; comment: 'Reporting whether we can and should store edit session as part of Continue On.';
827
outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The outcome of checking whether we can store an edit session as part of the Continue On flow.' };
828
};
829
830
// If the user is already signed in, we should store edit session
831
if (this.editSessionsStorageService.isSignedIn) {
832
return this.hasEditSession();
833
}
834
835
// If the user has been asked before and said no, don't use edit sessions
836
if (this.configurationService.getValue(useEditSessionsWithContinueOn) === 'off') {
837
this.telemetryService.publicLog2<EditSessionsAuthCheckEvent, EditSessionsAuthCheckClassification>('continueOn.editSessions.canStore.outcome', { outcome: 'disabledEditSessionsViaSetting' });
838
return false;
839
}
840
841
// Prompt the user to use edit sessions if they currently could benefit from using it
842
if (this.hasEditSession()) {
843
const disposables = new DisposableStore();
844
const quickpick = disposables.add(this.quickInputService.createQuickPick<IQuickPickItem>());
845
quickpick.placeholder = localize('continue with cloud changes', "Select whether to bring your working changes with you");
846
quickpick.ok = false;
847
quickpick.ignoreFocusOut = true;
848
const withCloudChanges = { label: localize('with cloud changes', "Yes, continue with my working changes") };
849
const withoutCloudChanges = { label: localize('without cloud changes', "No, continue without my working changes") };
850
quickpick.items = [withCloudChanges, withoutCloudChanges];
851
852
const continueWithCloudChanges = await new Promise<boolean>((resolve, reject) => {
853
disposables.add(quickpick.onDidAccept(() => {
854
resolve(quickpick.selectedItems[0] === withCloudChanges);
855
disposables.dispose();
856
}));
857
disposables.add(quickpick.onDidHide(() => {
858
reject(new CancellationError());
859
disposables.dispose();
860
}));
861
quickpick.show();
862
});
863
864
if (!continueWithCloudChanges) {
865
this.telemetryService.publicLog2<EditSessionsAuthCheckEvent, EditSessionsAuthCheckClassification>('continueOn.editSessions.canStore.outcome', { outcome: 'didNotEnableEditSessionsWhenPrompted' });
866
return continueWithCloudChanges;
867
}
868
869
const initialized = await this.editSessionsStorageService.initialize('write');
870
if (!initialized) {
871
this.telemetryService.publicLog2<EditSessionsAuthCheckEvent, EditSessionsAuthCheckClassification>('continueOn.editSessions.canStore.outcome', { outcome: 'didNotEnableEditSessionsWhenPrompted' });
872
}
873
return initialized;
874
}
875
876
return false;
877
}
878
879
//#region Continue Edit Session extension contribution point
880
881
private registerContributedEditSessionOptions() {
882
continueEditSessionExtPoint.setHandler(extensions => {
883
const continueEditSessionOptions: ContinueEditSessionItem[] = [];
884
for (const extension of extensions) {
885
if (!isProposedApiEnabled(extension.description, 'contribEditSessions')) {
886
continue;
887
}
888
if (!Array.isArray(extension.value)) {
889
continue;
890
}
891
for (const contribution of extension.value) {
892
const command = MenuRegistry.getCommand(contribution.command);
893
if (!command) {
894
return;
895
}
896
897
const icon = command.icon;
898
const title = typeof command.title === 'string' ? command.title : command.title.value;
899
const when = ContextKeyExpr.deserialize(contribution.when);
900
901
continueEditSessionOptions.push(new ContinueEditSessionItem(
902
ThemeIcon.isThemeIcon(icon) ? `$(${icon.id}) ${title}` : title,
903
command.id,
904
command.source?.title,
905
when,
906
contribution.documentation
907
));
908
909
if (contribution.qualifiedName) {
910
this.generateStandaloneOptionCommand(command.id, contribution.qualifiedName, contribution.category ?? command.category, when, contribution.remoteGroup);
911
}
912
}
913
}
914
this.continueEditSessionOptions = continueEditSessionOptions;
915
});
916
}
917
918
private generateStandaloneOptionCommand(commandId: string, qualifiedName: string, category: string | ILocalizedString | undefined, when: ContextKeyExpression | undefined, remoteGroup: string | undefined) {
919
const command: IAction2Options = {
920
id: `${continueWorkingOnCommand.id}.${commandId}`,
921
title: { original: qualifiedName, value: qualifiedName },
922
category: typeof category === 'string' ? { original: category, value: category } : category,
923
precondition: when,
924
f1: true
925
};
926
927
if (!this.registeredCommands.has(command.id)) {
928
this.registeredCommands.add(command.id);
929
930
this._register(registerAction2(class StandaloneContinueOnOption extends Action2 {
931
constructor() {
932
super(command);
933
}
934
935
async run(accessor: ServicesAccessor): Promise<void> {
936
return accessor.get(ICommandService).executeCommand(continueWorkingOnCommand.id, undefined, commandId);
937
}
938
}));
939
940
if (remoteGroup !== undefined) {
941
MenuRegistry.appendMenuItem(MenuId.StatusBarRemoteIndicatorMenu, {
942
group: remoteGroup,
943
command: command,
944
when: command.precondition
945
});
946
}
947
}
948
}
949
950
private registerContinueInLocalFolderAction(): void {
951
const that = this;
952
this._register(registerAction2(class ContinueInLocalFolderAction extends Action2 {
953
constructor() {
954
super(openLocalFolderCommand);
955
}
956
957
async run(accessor: ServicesAccessor): Promise<URI | undefined> {
958
const selection = await that.fileDialogService.showOpenDialog({
959
title: localize('continueEditSession.openLocalFolder.title.v2', 'Select a local folder to continue working in'),
960
canSelectFolders: true,
961
canSelectMany: false,
962
canSelectFiles: false,
963
availableFileSystems: [Schemas.file]
964
});
965
966
return selection?.length !== 1 ? undefined : URI.from({
967
scheme: that.productService.urlProtocol,
968
authority: Schemas.file,
969
path: selection[0].path
970
});
971
}
972
}));
973
974
if (getVirtualWorkspaceLocation(this.contextService.getWorkspace()) !== undefined && isNative) {
975
this.generateStandaloneOptionCommand(openLocalFolderCommand.id, localize('continueWorkingOn.existingLocalFolder', 'Continue Working in Existing Local Folder'), undefined, openLocalFolderCommand.precondition, undefined);
976
}
977
}
978
979
private async pickContinueEditSessionDestination(): Promise<string | undefined> {
980
const disposables = new DisposableStore();
981
const quickPick = disposables.add(this.quickInputService.createQuickPick<ContinueEditSessionItem>({ useSeparators: true }));
982
983
const workspaceContext = this.contextService.getWorkbenchState() === WorkbenchState.FOLDER
984
? this.contextService.getWorkspace().folders[0].name
985
: this.contextService.getWorkspace().folders.map((folder) => folder.name).join(', ');
986
quickPick.placeholder = localize('continueEditSessionPick.title.v2', "Select a development environment to continue working on {0} in", `'${workspaceContext}'`);
987
quickPick.items = this.createPickItems();
988
this.extensionService.onDidChangeExtensions(() => {
989
quickPick.items = this.createPickItems();
990
});
991
992
const command = await new Promise<string | undefined>((resolve, reject) => {
993
disposables.add(quickPick.onDidHide(() => {
994
disposables.dispose();
995
resolve(undefined);
996
}));
997
998
disposables.add(quickPick.onDidAccept((e) => {
999
const selection = quickPick.activeItems[0].command;
1000
1001
if (selection === installAdditionalContinueOnOptionsCommand.id) {
1002
void this.commandService.executeCommand(installAdditionalContinueOnOptionsCommand.id);
1003
} else {
1004
resolve(selection);
1005
quickPick.hide();
1006
}
1007
}));
1008
1009
quickPick.show();
1010
1011
disposables.add(quickPick.onDidTriggerItemButton(async (e) => {
1012
if (e.item.documentation !== undefined) {
1013
const uri = URI.isUri(e.item.documentation) ? URI.parse(e.item.documentation) : await this.commandService.executeCommand(e.item.documentation);
1014
void this.openerService.open(uri, { openExternal: true });
1015
}
1016
}));
1017
});
1018
1019
quickPick.dispose();
1020
1021
return command;
1022
}
1023
1024
private async resolveDestination(command: string): Promise<URI | 'noDestinationUri' | undefined> {
1025
type EvaluateContinueOnDestinationEvent = { outcome: string; selection: string };
1026
type EvaluateContinueOnDestinationClassification = {
1027
owner: 'joyceerhl'; comment: 'Reporting the outcome of evaluating a selected Continue On destination option.';
1028
selection: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The selected Continue On destination option.' };
1029
outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The outcome of evaluating the selected Continue On destination option.' };
1030
};
1031
1032
try {
1033
const uri = await this.commandService.executeCommand(command);
1034
1035
// Some continue on commands do not return a URI
1036
// to support extensions which want to be in control
1037
// of how the destination is opened
1038
if (uri === undefined) {
1039
this.telemetryService.publicLog2<EvaluateContinueOnDestinationEvent, EvaluateContinueOnDestinationClassification>('continueOn.openDestination.outcome', { selection: command, outcome: 'noDestinationUri' });
1040
return 'noDestinationUri';
1041
}
1042
1043
if (URI.isUri(uri)) {
1044
this.telemetryService.publicLog2<EvaluateContinueOnDestinationEvent, EvaluateContinueOnDestinationClassification>('continueOn.openDestination.outcome', { selection: command, outcome: 'resolvedUri' });
1045
return uri;
1046
}
1047
1048
this.telemetryService.publicLog2<EvaluateContinueOnDestinationEvent, EvaluateContinueOnDestinationClassification>('continueOn.openDestination.outcome', { selection: command, outcome: 'invalidDestination' });
1049
return undefined;
1050
} catch (ex) {
1051
if (ex instanceof CancellationError) {
1052
this.telemetryService.publicLog2<EvaluateContinueOnDestinationEvent, EvaluateContinueOnDestinationClassification>('continueOn.openDestination.outcome', { selection: command, outcome: 'cancelled' });
1053
} else {
1054
this.telemetryService.publicLog2<EvaluateContinueOnDestinationEvent, EvaluateContinueOnDestinationClassification>('continueOn.openDestination.outcome', { selection: command, outcome: 'unknownError' });
1055
}
1056
return undefined;
1057
}
1058
}
1059
1060
private createPickItems(): (ContinueEditSessionItem | IQuickPickSeparator)[] {
1061
const items = [...this.continueEditSessionOptions].filter((option) => option.when === undefined || this.contextKeyService.contextMatchesRules(option.when));
1062
1063
if (getVirtualWorkspaceLocation(this.contextService.getWorkspace()) !== undefined && isNative) {
1064
items.push(new ContinueEditSessionItem(
1065
'$(folder) ' + localize('continueEditSessionItem.openInLocalFolder.v2', 'Open in Local Folder'),
1066
openLocalFolderCommand.id,
1067
localize('continueEditSessionItem.builtin', 'Built-in')
1068
));
1069
}
1070
1071
const sortedItems: (ContinueEditSessionItem | IQuickPickSeparator)[] = items.sort((item1, item2) => item1.label.localeCompare(item2.label));
1072
return sortedItems.concat({ type: 'separator' }, new ContinueEditSessionItem(installAdditionalContinueOnOptionsCommand.title, installAdditionalContinueOnOptionsCommand.id));
1073
}
1074
}
1075
1076
const infoButtonClass = ThemeIcon.asClassName(Codicon.info);
1077
class ContinueEditSessionItem implements IQuickPickItem {
1078
public readonly buttons: IQuickInputButton[] | undefined;
1079
1080
constructor(
1081
public readonly label: string,
1082
public readonly command: string,
1083
public readonly description?: string,
1084
public readonly when?: ContextKeyExpression,
1085
public readonly documentation?: string,
1086
) {
1087
if (documentation !== undefined) {
1088
this.buttons = [{
1089
iconClass: infoButtonClass,
1090
tooltip: localize('learnMoreTooltip', 'Learn More'),
1091
}];
1092
}
1093
}
1094
}
1095
1096
interface ICommand {
1097
command: string;
1098
group: string;
1099
when: string;
1100
documentation?: string;
1101
qualifiedName?: string;
1102
category?: string;
1103
remoteGroup?: string;
1104
}
1105
1106
const continueEditSessionExtPoint = ExtensionsRegistry.registerExtensionPoint<ICommand[]>({
1107
extensionPoint: 'continueEditSession',
1108
jsonSchema: {
1109
description: localize('continueEditSessionExtPoint', 'Contributes options for continuing the current edit session in a different environment'),
1110
type: 'array',
1111
items: {
1112
type: 'object',
1113
properties: {
1114
command: {
1115
description: localize('continueEditSessionExtPoint.command', 'Identifier of the command to execute. The command must be declared in the \'commands\'-section and return a URI representing a different environment where the current edit session can be continued.'),
1116
type: 'string'
1117
},
1118
group: {
1119
description: localize('continueEditSessionExtPoint.group', 'Group into which this item belongs.'),
1120
type: 'string'
1121
},
1122
qualifiedName: {
1123
description: localize('continueEditSessionExtPoint.qualifiedName', 'A fully qualified name for this item which is used for display in menus.'),
1124
type: 'string'
1125
},
1126
description: {
1127
description: localize('continueEditSessionExtPoint.description', "The url, or a command that returns the url, to the option's documentation page."),
1128
type: 'string'
1129
},
1130
remoteGroup: {
1131
description: localize('continueEditSessionExtPoint.remoteGroup', 'Group into which this item belongs in the remote indicator.'),
1132
type: 'string'
1133
},
1134
when: {
1135
description: localize('continueEditSessionExtPoint.when', 'Condition which must be true to show this item.'),
1136
type: 'string'
1137
}
1138
},
1139
required: ['command']
1140
}
1141
}
1142
});
1143
1144
//#endregion
1145
1146
const workbenchRegistry = Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench);
1147
workbenchRegistry.registerWorkbenchContribution(EditSessionsContribution, LifecyclePhase.Restored);
1148
1149
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
1150
...workbenchConfigurationNodeBase,
1151
'properties': {
1152
'workbench.experimental.cloudChanges.autoStore': {
1153
enum: ['onShutdown', 'off'],
1154
enumDescriptions: [
1155
localize('autoStoreWorkingChanges.onShutdown', "Automatically store current working changes in the cloud on window close."),
1156
localize('autoStoreWorkingChanges.off', "Never attempt to automatically store working changes in the cloud.")
1157
],
1158
'type': 'string',
1159
'tags': ['experimental', 'usesOnlineServices'],
1160
'default': 'off',
1161
'markdownDescription': localize('autoStoreWorkingChangesDescription', "Controls whether to automatically store available working changes in the cloud for the current workspace. This setting has no effect in the web."),
1162
},
1163
'workbench.cloudChanges.autoResume': {
1164
enum: ['onReload', 'off'],
1165
enumDescriptions: [
1166
localize('autoResumeWorkingChanges.onReload', "Automatically resume available working changes from the cloud on window reload."),
1167
localize('autoResumeWorkingChanges.off', "Never attempt to resume working changes from the cloud.")
1168
],
1169
'type': 'string',
1170
'tags': ['usesOnlineServices'],
1171
'default': 'onReload',
1172
'markdownDescription': localize('autoResumeWorkingChanges', "Controls whether to automatically resume available working changes stored in the cloud for the current workspace."),
1173
},
1174
'workbench.cloudChanges.continueOn': {
1175
enum: ['prompt', 'off'],
1176
enumDescriptions: [
1177
localize('continueOnCloudChanges.promptForAuth', 'Prompt the user to sign in to store working changes in the cloud with Continue Working On.'),
1178
localize('continueOnCloudChanges.off', 'Do not store working changes in the cloud with Continue Working On unless the user has already turned on Cloud Changes.')
1179
],
1180
type: 'string',
1181
tags: ['usesOnlineServices'],
1182
default: 'prompt',
1183
markdownDescription: localize('continueOnCloudChanges', 'Controls whether to prompt the user to store working changes in the cloud when using Continue Working On.')
1184
},
1185
'workbench.experimental.cloudChanges.partialMatches.enabled': {
1186
'type': 'boolean',
1187
'tags': ['experimental', 'usesOnlineServices'],
1188
'default': false,
1189
'markdownDescription': localize('cloudChangesPartialMatchesEnabled', "Controls whether to surface cloud changes which partially match the current session.")
1190
}
1191
}
1192
});
1193
1194