diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index b7b2cd1833eea..82edafd10b78b 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -278,6 +278,10 @@ "name": "vs/workbench/contrib/userDataSync", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/sessionSync", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/views", "project": "vscode-workbench" @@ -426,6 +430,10 @@ "name": "vs/workbench/services/userDataSync", "project": "vscode-workbench" }, + { + "name": "vs/workbench/services/sessionSync", + "project": "vscode-workbench" + }, { "name": "vs/workbench/services/views", "project": "vscode-workbench" diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index f822373104dc4..8cc869d21df79 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -153,6 +153,8 @@ export interface IProductConfiguration { readonly 'configurationSync.store'?: ConfigurationSyncStore; + readonly 'sessionSync.store'?: Omit; + readonly darwinUniversalAssetId?: string; } diff --git a/src/vs/platform/userDataSync/common/userDataSync.ts b/src/vs/platform/userDataSync/common/userDataSync.ts index f7c41f6220170..5c1516106bbf6 100644 --- a/src/vs/platform/userDataSync/common/userDataSync.ts +++ b/src/vs/platform/userDataSync/common/userDataSync.ts @@ -156,7 +156,7 @@ export interface IResourceRefHandle { created: number; } -export type ServerResource = SyncResource | 'machines'; +export type ServerResource = SyncResource | 'machines' | 'editSessions'; export type UserDataSyncStoreType = 'insiders' | 'stable'; export const IUserDataSyncStoreManagementService = createDecorator('IUserDataSyncStoreManagementService'); diff --git a/src/vs/workbench/contrib/sessionSync/browser/sessionSync.contribution.ts b/src/vs/workbench/contrib/sessionSync/browser/sessionSync.contribution.ts new file mode 100644 index 0000000000000..7e2c55e35e8af --- /dev/null +++ b/src/vs/workbench/contrib/sessionSync/browser/sessionSync.contribution.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; +import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; +import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { localize } from 'vs/nls'; +import { ISessionSyncWorkbenchService, Change, ChangeType, Folder, EditSession, FileType } from 'vs/workbench/services/sessionSync/common/sessionSync'; +import { ISCMService } from 'vs/workbench/contrib/scm/common/scm'; +import { IFileService } from 'vs/platform/files/common/files'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { URI } from 'vs/base/common/uri'; +import { joinPath, relativePath } from 'vs/base/common/resources'; +import { VSBuffer } from 'vs/base/common/buffer'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress'; +import { SessionSyncWorkbenchService } from 'vs/workbench/services/sessionSync/browser/sessionSyncWorkbenchService'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; + +registerSingleton(ISessionSyncWorkbenchService, SessionSyncWorkbenchService); + +const SYNC_TITLE = localize('session sync', 'Edit Sessions'); +const applyLatestCommand = { + id: 'workbench.sessionSync.actions.applyLatest', + title: localize('apply latest', "{0}: Apply Latest Edit Session", SYNC_TITLE), +}; +const storeLatestCommand = { + id: 'workbench.sessionSync.actions.storeLatest', + title: localize('store latest', "{0}: Store Latest Edit Session", SYNC_TITLE), +}; + +export class SessionSyncContribution extends Disposable implements IWorkbenchContribution { + + private registered = false; + + constructor( + @ISessionSyncWorkbenchService private readonly sessionSyncWorkbenchService: ISessionSyncWorkbenchService, + @IFileService private readonly fileService: IFileService, + @IProgressService private readonly progressService: IProgressService, + @ISCMService private readonly scmService: ISCMService, + @IConfigurationService private configurationService: IConfigurationService, + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, + ) { + super(); + + this.configurationService.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('workbench.experimental.sessionSync.enabled')) { + this.registerActions(); + } + }); + + this.registerActions(); + } + + private registerActions() { + if (this.registered || this.configurationService.getValue('workbench.experimental.sessionSync.enabled') === false) { + return; + } + + this.registerApplyEditSessionAction(); + this.registerStoreEditSessionAction(); + + this.registered = true; + } + + private registerApplyEditSessionAction(): void { + const that = this; + this._register(registerAction2(class ApplyEditSessionAction extends Action2 { + constructor() { + super({ + id: applyLatestCommand.id, + title: applyLatestCommand.title, + menu: { + id: MenuId.CommandPalette, + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + await that.progressService.withProgress({ + location: ProgressLocation.Notification, + title: localize('applying edit session', 'Applying edit session...') + }, async () => await that.applyEditSession()); + } + })); + } + + private registerStoreEditSessionAction(): void { + const that = this; + this._register(registerAction2(class StoreEditSessionAction extends Action2 { + constructor() { + super({ + id: storeLatestCommand.id, + title: storeLatestCommand.title, + menu: { + id: MenuId.CommandPalette, + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + await that.progressService.withProgress({ + location: ProgressLocation.Notification, + title: localize('storing edit session', 'Storing edit session...') + }, async () => await that.storeEditSession()); + } + })); + } + + async applyEditSession() { + const editSession = await this.sessionSyncWorkbenchService.read(); + if (!editSession) { + return; + } + + for (const folder of editSession.folders) { + const folderRoot = this.contextService.getWorkspace().folders.find((f) => f.name === folder.name); + if (!folderRoot) { + return; + } + + for (const { relativeFilePath, contents, type } of folder.workingChanges) { + const uri = joinPath(folderRoot.uri, relativeFilePath); + if (type === ChangeType.Addition) { + await this.fileService.writeFile(uri, VSBuffer.fromString(contents)); + } else if (type === ChangeType.Deletion && await this.fileService.exists(uri)) { + await this.fileService.del(uri); + } + } + } + } + + async storeEditSession() { + const folders: Folder[] = []; + + for (const repository of this.scmService.repositories) { + // Look through all resource groups and compute which files were added/modified/deleted + const trackedUris = repository.provider.groups.elements.reduce((resources, resourceGroups) => { + resourceGroups.elements.map((resource) => resources.add(resource.sourceUri)); + return resources; + }, new Set()); // A URI might appear in more than one resource group + + const workingChanges: Change[] = []; + let name = repository.provider.rootUri ? this.contextService.getWorkspaceFolder(repository.provider.rootUri)?.name : undefined; + + for (const uri of trackedUris) { + const workspaceFolder = this.contextService.getWorkspaceFolder(uri); + if (!workspaceFolder) { + continue; + } + + name = name ?? workspaceFolder.name; + const relativeFilePath = relativePath(workspaceFolder.uri, uri) ?? uri.path; + + // Only deal with file contents for now + try { + if (!(await this.fileService.stat(uri)).isFile) { + continue; + } + } catch { } + + if (await this.fileService.exists(uri)) { + workingChanges.push({ type: ChangeType.Addition, fileType: FileType.File, contents: (await this.fileService.readFile(uri)).value.toString(), relativeFilePath: relativeFilePath }); + } else { + // Assume it's a deletion + workingChanges.push({ type: ChangeType.Deletion, fileType: FileType.File, contents: undefined, relativeFilePath: relativeFilePath }); + } + } + + folders.push({ workingChanges, name: name ?? '' }); + } + + const data: EditSession = { folders, version: 1 }; + + await this.sessionSyncWorkbenchService.write(data); + } +} + +const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); +workbenchRegistry.registerWorkbenchContribution(SessionSyncContribution, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/sessionSync/test/browser/sessionSync.test.ts b/src/vs/workbench/contrib/sessionSync/test/browser/sessionSync.test.ts new file mode 100644 index 0000000000000..cecd825cbcb2f --- /dev/null +++ b/src/vs/workbench/contrib/sessionSync/test/browser/sessionSync.test.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { IFileService } from 'vs/platform/files/common/files'; +import { FileService } from 'vs/platform/files/common/fileService'; +import { Schemas } from 'vs/base/common/network'; +import { InMemoryFileSystemProvider } from 'vs/platform/files/common/inMemoryFilesystemProvider'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { NullLogService, ILogService } from 'vs/platform/log/common/log'; +import { SessionSyncContribution } from 'vs/workbench/contrib/sessionSync/browser/sessionSync.contribution'; +import { ProgressService } from 'vs/workbench/services/progress/browser/progressService'; +import { IProgressService } from 'vs/platform/progress/common/progress'; +import { ISCMService } from 'vs/workbench/contrib/scm/common/scm'; +import { SCMService } from 'vs/workbench/contrib/scm/common/scmService'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { mock } from 'vs/base/test/common/mock'; +import * as sinon from 'sinon'; +import * as assert from 'assert'; +import { ChangeType, FileType, ISessionSyncWorkbenchService } from 'vs/workbench/services/sessionSync/common/sessionSync'; +import { URI } from 'vs/base/common/uri'; +import { joinPath } from 'vs/base/common/resources'; + +const folderName = 'test-folder'; +const folderUri = URI.file(`/${folderName}`); + +suite('Edit session sync', () => { + let instantiationService: TestInstantiationService; + let sessionSyncContribution: SessionSyncContribution; + let fileService: FileService; + + const disposables = new DisposableStore(); + + setup(() => { + instantiationService = new TestInstantiationService(); + + // Set up filesystem + const logService = new NullLogService(); + fileService = disposables.add(new FileService(logService)); + const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + fileService.registerProvider(Schemas.file, fileSystemProvider); + + // Stub out all services + instantiationService.stub(ILogService, logService); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(ISessionSyncWorkbenchService, new class extends mock() { }); + instantiationService.stub(IProgressService, ProgressService); + instantiationService.stub(ISCMService, SCMService); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ workbench: { experimental: { sessionSync: { enabled: true } } } })); + instantiationService.stub(IWorkspaceContextService, new class extends mock() { + override getWorkspace() { + return { + id: 'workspace-id', + folders: [{ + uri: folderUri, + name: folderName, + index: 0, + toResource: (relativePath: string) => joinPath(folderUri, relativePath) + }] + }; + } + }); + + sessionSyncContribution = instantiationService.createInstance(SessionSyncContribution); + }); + + teardown(() => { + sinon.restore(); + disposables.clear(); + }); + + test('Can apply edit session', async function () { + const fileUri = joinPath(folderUri, 'dir1', 'README.md'); + const fileContents = '# readme'; + const editSession = { + version: 1, + folders: [ + { + name: folderName, + workingChanges: [ + { + relativeFilePath: 'dir1/README.md', + fileType: FileType.File, + contents: fileContents, + type: ChangeType.Addition + } + ] + } + ] + }; + + // Stub sync service to return edit session data + const sandbox = sinon.createSandbox(); + const readStub = sandbox.stub().returns(editSession); + instantiationService.stub(ISessionSyncWorkbenchService, 'read', readStub); + + // Create root folder + await fileService.createFolder(folderUri); + + // Apply edit session + await sessionSyncContribution.applyEditSession(); + + // Verify edit session was correctly applied + assert.equal((await fileService.readFile(fileUri)).value.toString(), fileContents); + }); +}); diff --git a/src/vs/workbench/services/sessionSync/browser/sessionSyncWorkbenchService.ts b/src/vs/workbench/services/sessionSync/browser/sessionSyncWorkbenchService.ts new file mode 100644 index 0000000000000..00d397f402050 --- /dev/null +++ b/src/vs/workbench/services/sessionSync/browser/sessionSyncWorkbenchService.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IFileService } from 'vs/platform/files/common/files'; +import { ILogService } from 'vs/platform/log/common/log'; +import { IProductService } from 'vs/platform/product/common/productService'; +import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; +import { IRequestService } from 'vs/platform/request/common/request'; +import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IAuthenticationProvider } from 'vs/platform/userDataSync/common/userDataSync'; +import { UserDataSyncStoreClient } from 'vs/platform/userDataSync/common/userDataSyncStoreService'; +import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { EditSession, ISessionSyncWorkbenchService } from 'vs/workbench/services/sessionSync/common/sessionSync'; + +export class SessionSyncWorkbenchService extends Disposable implements ISessionSyncWorkbenchService { + + _serviceBrand = undefined; + + private serverConfiguration = this.productService['sessionSync.store']; + private storeClient: UserDataSyncStoreClient | undefined; + + #authenticationInfo: { sessionId: string; token: string; providerId: string } | undefined; + private static CACHED_SESSION_STORAGE_KEY = 'editSessionSyncAccountPreference'; + + private initialized = false; + + constructor( + @IFileService private readonly fileService: IFileService, + @IStorageService private readonly storageService: IStorageService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, + @IExtensionService private readonly extensionService: IExtensionService, + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @ILogService private readonly logService: ILogService, + @IProductService private readonly productService: IProductService, + @IRequestService private readonly requestService: IRequestService, + ) { + super(); + + // If the user signs out of the current session, reset our cached auth state in memory and on disk + this._register(this.authenticationService.onDidChangeSessions((e) => this.onDidChangeSessions(e.event))); + + // If another window changes the preferred session storage, reset our cached auth state in memory + this._register(this.storageService.onDidChangeValue(e => this.onDidChangeStorage(e))); + } + + /** + * + * @param editSession An object representing edit session state to be restored. + */ + async write(editSession: EditSession): Promise { + this.initialized = await this.waitAndInitialize(); + if (!this.initialized) { + throw new Error('Unable to store edit session.'); + } + + await this.storeClient?.write('editSessions', JSON.stringify(editSession), null); + } + + /** + * + * @returns An object representing the latest saved edit session state, if any. + */ + async read(): Promise { + this.initialized = await this.waitAndInitialize(); + if (!this.initialized) { + throw new Error('Unable to apply latest edit session.'); + } + + // Pull latest session data from service + const sessionData = await this.storeClient?.read('editSessions', null); + if (!sessionData?.content) { + return; + } + + // TODO@joyceerhl Validate session data, check schema version + return JSON.parse(sessionData.content); + } + + /** + * + * Ensures that the store client is initialized, + * meaning that authentication is configured and it + * can be used to communicate with the remote storage service + */ + private async waitAndInitialize(): Promise { + // Wait for authentication extensions to be registered + await this.extensionService.whenInstalledExtensionsRegistered(); + + if (!this.serverConfiguration?.url) { + throw new Error('Unable to initialize sessions sync as session sync preference is not configured in product.json.'); + } + + if (!this.storeClient) { + this.storeClient = new UserDataSyncStoreClient(URI.parse(this.serverConfiguration.url), this.productService, this.requestService, this.logService, this.environmentService, this.fileService, this.storageService); + } + + // If we already have an existing auth session in memory, use that + if (this.#authenticationInfo !== undefined) { + return true; + } + + // If the user signed in previously and the session is still available, reuse that without prompting the user again + if (this.existingSessionId) { + const existing = await this.getExistingSession(); + if (existing !== undefined) { + this.#authenticationInfo = { sessionId: existing.session.id, token: existing.session.accessToken, providerId: existing.session.providerId }; + this.storeClient.setAuthToken(this.#authenticationInfo.token, this.#authenticationInfo.providerId); + return true; + } + } + + // Ask the user to pick a preferred account + const session = await this.getAccountPreference(); + if (session !== undefined) { + this.#authenticationInfo = { sessionId: session.id, token: session.accessToken, providerId: session.providerId }; + this.storeClient.setAuthToken(this.#authenticationInfo.token, this.#authenticationInfo.providerId); + this.existingSessionId = session.id; + return true; + } + + return false; + } + + /** + * + * Prompts the user to pick an authentication option for storing and getting edit sessions. + */ + private async getAccountPreference(): Promise { + const quickpick = this.quickInputService.createQuickPick(); + quickpick.title = localize('account preference', 'Edit Sessions'); + quickpick.ok = false; + quickpick.placeholder = localize('choose account placeholder', "Select an account to sign in"); + quickpick.ignoreFocusOut = true; + // TODO@joyceerhl Should we be showing sessions here? + quickpick.items = await this.getAllSessions(); + + return new Promise((resolve, reject) => { + quickpick.onDidHide((e) => quickpick.dispose()); + quickpick.onDidAccept((e) => { + resolve(quickpick.selectedItems[0].session); + quickpick.hide(); + }); + quickpick.show(); + }); + } + + /** + * + * Returns all authentication sessions available from {@link getAuthenticationProviders}. + */ + private async getAllSessions() { + const options = []; + const authenticationProviders = await this.getAuthenticationProviders(); + + for (const provider of authenticationProviders) { + const sessions = await this.authenticationService.getSessions(provider.id, provider.scopes); + + for (const session of sessions) { + options.push({ + label: session.account.label, + description: this.authenticationService.getLabel(provider.id), + session: { ...session, providerId: provider.id } + }); + } + } + + return options; + } + + /** + * + * Returns all authentication providers which can be used to authenticate + * to the remote storage service, based on product.json configuration + * and registered authentication providers. + */ + private async getAuthenticationProviders() { + if (!this.serverConfiguration) { + throw new Error('Unable to get configured authentication providers as session sync preference is not configured in product.json.'); + } + + // Get the list of authentication providers configured in product.json + const authenticationProviders = this.serverConfiguration.authenticationProviders; + const configuredAuthenticationProviders = Object.keys(authenticationProviders).reduce((result, id) => { + result.push({ id, scopes: authenticationProviders[id].scopes }); + return result; + }, []); + + // Filter out anything that isn't currently available through the authenticationService + const availableAuthenticationProviders = this.authenticationService.declaredProviders; + + return configuredAuthenticationProviders.filter(({ id }) => availableAuthenticationProviders.some(provider => provider.id === id)); + } + + private get existingSessionId() { + return this.storageService.get(SessionSyncWorkbenchService.CACHED_SESSION_STORAGE_KEY, StorageScope.GLOBAL); + } + + private set existingSessionId(sessionId: string | undefined) { + if (sessionId === undefined) { + this.storageService.remove(SessionSyncWorkbenchService.CACHED_SESSION_STORAGE_KEY, StorageScope.GLOBAL); + } else { + this.storageService.store(SessionSyncWorkbenchService.CACHED_SESSION_STORAGE_KEY, sessionId, StorageScope.GLOBAL, StorageTarget.USER); + } + } + + private async getExistingSession() { + const accounts = await this.getAllSessions(); + return accounts.find((account) => account.session.id === this.existingSessionId); + } + + private async onDidChangeStorage(e: IStorageValueChangeEvent): Promise { + if (e.key === SessionSyncWorkbenchService.CACHED_SESSION_STORAGE_KEY + && e.scope === StorageScope.GLOBAL + && this.#authenticationInfo?.sessionId !== this.existingSessionId + ) { + this.#authenticationInfo = undefined; + this.initialized = false; + } + } + + private onDidChangeSessions(e: AuthenticationSessionsChangeEvent): void { + if (this.#authenticationInfo?.sessionId && e.removed.find(session => session.id === this.#authenticationInfo?.sessionId)) { + this.#authenticationInfo = undefined; + this.existingSessionId = undefined; + this.initialized = false; + } + } +} diff --git a/src/vs/workbench/services/sessionSync/common/sessionSync.ts b/src/vs/workbench/services/sessionSync/common/sessionSync.ts new file mode 100644 index 0000000000000..32504c93d1e58 --- /dev/null +++ b/src/vs/workbench/services/sessionSync/common/sessionSync.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export const ISessionSyncWorkbenchService = createDecorator('ISessionSyncWorkbenchService'); +export interface ISessionSyncWorkbenchService { + _serviceBrand: undefined; + + read(): Promise; + write(editSession: EditSession): Promise; +} + +export enum ChangeType { + Addition = 1, + Deletion = 2, +} + +export enum FileType { + File = 1, +} + +interface Addition { + relativeFilePath: string; + fileType: FileType.File; + contents: string; + type: ChangeType.Addition; +} + +interface Deletion { + relativeFilePath: string; + fileType: FileType.File; + contents: undefined; + type: ChangeType.Deletion; +} + +export type Change = Addition | Deletion; + +export interface Folder { + name: string; + workingChanges: Change[]; +} + +export interface EditSession { + version: 1; + folders: Folder[]; +} diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index a9e13a7d31d27..260cd94b63af0 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -325,6 +325,9 @@ import 'vs/workbench/contrib/userDataSync/browser/userDataSync.contribution'; // Profiles import 'vs/workbench/contrib/profiles/common/profiles.contribution'; +// Continue Edit Session +import 'vs/workbench/contrib/sessionSync/browser/sessionSync.contribution'; + // Code Actions import 'vs/workbench/contrib/codeActions/browser/codeActions.contribution';