From 9a8cc086138a7e8363195fc137db4ca5431c091d Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 14 Sep 2022 17:25:42 +0200 Subject: [PATCH] Support syncing profiles manifest --- src/vs/base/common/product.ts | 3 + .../userDataProfile/common/userDataProfile.ts | 10 + .../common/userDataProfileService.test.ts | 15 +- .../common/abstractSynchronizer.ts | 2 +- .../common/userDataProfilesManifestMerge.ts | 135 +++++++++ .../common/userDataProfilesManifestSync.ts | 280 ++++++++++++++++++ .../userDataSync/common/userDataSync.ts | 9 +- .../common/userDataSyncService.ts | 65 ++-- .../common/userDataAutoSyncService.test.ts | 2 + .../userDataProfilesManifestMerge.test.ts | 176 +++++++++++ .../userDataProfilesManifestSync.test.ts | 216 ++++++++++++++ .../test/common/userDataSyncClient.ts | 16 +- .../test/common/userDataSyncService.test.ts | 22 +- .../userDataSync/common/userDataSync.ts | 1 + 14 files changed, 900 insertions(+), 52 deletions(-) create mode 100644 src/vs/platform/userDataSync/common/userDataProfilesManifestMerge.ts create mode 100644 src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts create mode 100644 src/vs/platform/userDataSync/test/common/userDataProfilesManifestMerge.test.ts create mode 100644 src/vs/platform/userDataSync/test/common/userDataProfilesManifestSync.test.ts diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 1ae8079810e8b..1620aa3bd2c83 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -156,6 +156,9 @@ export interface IProductConfiguration { readonly 'editSessions.store'?: Omit; readonly darwinUniversalAssetId?: string; + + // experimental + readonly enableSyncingProfiles?: boolean; } export type ImportantExtensionTip = { name: string; languages?: string[]; pattern?: string; isExtensionPack?: boolean; whenNotInstalled?: string[] }; diff --git a/src/vs/platform/userDataProfile/common/userDataProfile.ts b/src/vs/platform/userDataProfile/common/userDataProfile.ts index cdde6fb2af373..5223d7a9c40d2 100644 --- a/src/vs/platform/userDataProfile/common/userDataProfile.ts +++ b/src/vs/platform/userDataProfile/common/userDataProfile.ts @@ -539,3 +539,13 @@ export class UserDataProfilesService extends Disposable implements IUserDataProf protected getStoredProfileAssociations(): StoredProfileAssociations { return {}; } protected saveStoredProfileAssociations(storedProfileAssociations: StoredProfileAssociations): void { throw new Error('not implemented'); } } + +export class InMemoryUserDataProfilesService extends UserDataProfilesService { + private storedProfiles: StoredUserDataProfile[] = []; + protected override getStoredProfiles(): StoredUserDataProfile[] { return this.storedProfiles; } + protected override saveStoredProfiles(storedProfiles: StoredUserDataProfile[]): void { this.storedProfiles = storedProfiles; } + + private storedProfileAssociations: StoredProfileAssociations = {}; + protected override getStoredProfileAssociations(): StoredProfileAssociations { return this.storedProfileAssociations; } + protected override saveStoredProfileAssociations(storedProfileAssociations: StoredProfileAssociations): void { this.storedProfileAssociations = storedProfileAssociations; } +} diff --git a/src/vs/platform/userDataProfile/test/common/userDataProfileService.test.ts b/src/vs/platform/userDataProfile/test/common/userDataProfileService.test.ts index a4cc6a8f50c69..3e6d027eead80 100644 --- a/src/vs/platform/userDataProfile/test/common/userDataProfileService.test.ts +++ b/src/vs/platform/userDataProfile/test/common/userDataProfileService.test.ts @@ -13,7 +13,7 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { InMemoryFileSystemProvider } from 'vs/platform/files/common/inMemoryFilesystemProvider'; import { AbstractNativeEnvironmentService } from 'vs/platform/environment/common/environmentService'; import product from 'vs/platform/product/common/product'; -import { StoredProfileAssociations, StoredUserDataProfile, UserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; +import { InMemoryUserDataProfilesService, UserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; import { UriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentityService'; const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); @@ -25,17 +25,6 @@ class TestEnvironmentService extends AbstractNativeEnvironmentService { override get userRoamingDataHome() { return this._appSettingsHome.with({ scheme: Schemas.vscodeUserData }); } } -class TestUserDataProfilesService extends UserDataProfilesService { - - private storedProfiles: StoredUserDataProfile[] = []; - protected override getStoredProfiles(): StoredUserDataProfile[] { return this.storedProfiles; } - protected override saveStoredProfiles(storedProfiles: StoredUserDataProfile[]): void { this.storedProfiles = storedProfiles; } - - private storedProfileAssociations: StoredProfileAssociations = {}; - protected override getStoredProfileAssociations(): StoredProfileAssociations { return this.storedProfileAssociations; } - protected override saveStoredProfileAssociations(storedProfileAssociations: StoredProfileAssociations): void { this.storedProfileAssociations = storedProfileAssociations; } -} - suite('UserDataProfileService (Common)', () => { const disposables = new DisposableStore(); @@ -50,7 +39,7 @@ suite('UserDataProfileService (Common)', () => { disposables.add(fileService.registerProvider(Schemas.vscodeUserData, fileSystemProvider)); environmentService = new TestEnvironmentService(joinPath(ROOT, 'User')); - testObject = new TestUserDataProfilesService(environmentService, fileService, new UriIdentityService(fileService), logService); + testObject = new InMemoryUserDataProfilesService(environmentService, fileService, new UriIdentityService(fileService), logService); testObject.setEnablement(true); }); diff --git a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts index 054565bd9d109..db2c4d178fe99 100644 --- a/src/vs/platform/userDataSync/common/abstractSynchronizer.ts +++ b/src/vs/platform/userDataSync/common/abstractSynchronizer.ts @@ -121,7 +121,7 @@ export abstract class AbstractSynchroniser extends Disposable implements IUserDa private hasSyncResourceStateVersionChanged: boolean = false; protected readonly syncResourceLogLabel: string; - private syncHeaders: IHeaders = {}; + protected syncHeaders: IHeaders = {}; constructor( readonly resource: SyncResource, diff --git a/src/vs/platform/userDataSync/common/userDataProfilesManifestMerge.ts b/src/vs/platform/userDataSync/common/userDataProfilesManifestMerge.ts new file mode 100644 index 0000000000000..202b2fef69c8c --- /dev/null +++ b/src/vs/platform/userDataSync/common/userDataProfilesManifestMerge.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IUserDataProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; +import { ISyncUserDataProfile } from 'vs/platform/userDataSync/common/userDataSync'; + +interface IRelaxedMergeResult { + local: { added: ISyncUserDataProfile[]; removed: IUserDataProfile[]; updated: ISyncUserDataProfile[] }; + remote: { added: IUserDataProfile[]; removed: ISyncUserDataProfile[]; updated: IUserDataProfile[] } | null; +} + +export type IMergeResult = Required; + +interface IUserDataProfileInfo { + readonly id: string; + readonly name: string; +} + +export function merge(local: IUserDataProfile[], remote: ISyncUserDataProfile[] | null, lastSync: ISyncUserDataProfile[] | null, ignored: string[]): IMergeResult { + const result: IRelaxedMergeResult = { + local: { + added: [], + removed: [], + updated: [], + }, remote: { + added: [], + removed: [], + updated: [], + } + }; + + if (!remote) { + const added = local.filter(({ id }) => !ignored.includes(id)); + if (added.length) { + result.remote!.added = added; + } else { + result.remote = null; + } + return result; + } + + const localToRemote = compare(local, remote, ignored); + if (localToRemote.added.length > 0 || localToRemote.removed.length > 0 || localToRemote.updated.length > 0) { + + const baseToLocal = compare(lastSync, local, ignored); + const baseToRemote = compare(lastSync, remote, ignored); + + // Remotely removed profiles + for (const id of baseToRemote.removed) { + const e = local.find(profile => profile.id === id); + if (e) { + result.local.removed.push(e); + } + } + + // Remotely added profiles + for (const id of baseToRemote.added) { + const remoteProfile = remote.find(profile => profile.id === id)!; + // Got added in local + if (baseToLocal.added.includes(id)) { + // Is different from local to remote + if (localToRemote.updated.includes(id)) { + // Remote wins always + result.local.updated.push(remoteProfile); + } + } else { + result.local.added.push(remoteProfile); + } + } + + // Remotely updated profiles + for (const id of baseToRemote.updated) { + // Remote wins always + result.local.updated.push(remote.find(profile => profile.id === id)!); + } + + // Locally added profiles + for (const id of baseToLocal.added) { + // Not there in remote + if (!baseToRemote.added.includes(id)) { + result.remote!.added.push(local.find(profile => profile.id === id)!); + } + } + + // Locally updated profiles + for (const id of baseToLocal.updated) { + // If removed in remote + if (baseToRemote.removed.includes(id)) { + continue; + } + + // If not updated in remote + if (!baseToRemote.updated.includes(id)) { + result.remote!.updated.push(local.find(profile => profile.id === id)!); + } + } + + // Locally removed profiles + for (const id of baseToLocal.removed) { + result.remote!.removed.push(remote.find(profile => profile.id === id)!); + } + } + + if (result.remote!.added.length === 0 && result.remote!.removed.length === 0 && result.remote!.updated.length === 0) { + result.remote = null; + } + + return result; +} + +function compare(from: IUserDataProfileInfo[] | null, to: IUserDataProfileInfo[], ignoredProfiles: string[]): { added: string[]; removed: string[]; updated: string[] } { + from = from ? from.filter(({ id }) => !ignoredProfiles.includes(id)) : []; + to = to.filter(({ id }) => !ignoredProfiles.includes(id)); + const fromKeys = from.map(({ id }) => id); + const toKeys = to.map(({ id }) => id); + const added = toKeys.filter(key => fromKeys.indexOf(key) === -1); + const removed = fromKeys.filter(key => toKeys.indexOf(key) === -1); + const updated: string[] = []; + + for (const { id, name } of from) { + if (removed.includes(id)) { + continue; + } + const toProfile = to.find(p => p.id === id); + if (!toProfile + || toProfile.name !== name + ) { + updated.push(id); + } + } + + return { added, removed, updated }; +} diff --git a/src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts b/src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts new file mode 100644 index 0000000000000..0401ede2383b8 --- /dev/null +++ b/src/vs/platform/userDataSync/common/userDataProfilesManifestSync.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { toFormattedString } from 'vs/base/common/jsonFormatter'; +import { URI } from 'vs/base/common/uri'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IFileService } from 'vs/platform/files/common/files'; +import { IStorageService } from 'vs/platform/storage/common/storage'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; +import { IUserDataProfile, IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; +import { AbstractSynchroniser, IAcceptResult, IMergeResult, IResourcePreview } from 'vs/platform/userDataSync/common/abstractSynchronizer'; +import { merge } from 'vs/platform/userDataSync/common/userDataProfilesManifestMerge'; +import { Change, IRemoteUserData, ISyncResourceHandle, IUserDataSyncBackupStoreService, IUserDataSynchroniser, IUserDataSyncLogService, IUserDataSyncEnablementService, IUserDataSyncStoreService, SyncResource, USER_DATA_SYNC_SCHEME, ISyncUserDataProfile, ISyncData } from 'vs/platform/userDataSync/common/userDataSync'; + +export interface IUserDataProfileManifestResourceMergeResult extends IAcceptResult { + readonly local: { added: ISyncUserDataProfile[]; removed: IUserDataProfile[]; updated: ISyncUserDataProfile[] }; + readonly remote: { added: IUserDataProfile[]; removed: ISyncUserDataProfile[]; updated: IUserDataProfile[] } | null; +} + +export interface IUserDataProfilesManifestResourcePreview extends IResourcePreview { + readonly previewResult: IUserDataProfileManifestResourceMergeResult; + readonly remoteProfiles: ISyncUserDataProfile[] | null; +} + +export class UserDataProfilesManifestSynchroniser extends AbstractSynchroniser implements IUserDataSynchroniser { + + private static readonly PROFILES_DATA_URI = URI.from({ scheme: USER_DATA_SYNC_SCHEME, authority: 'profiles', path: `/profiles.json` }); + + protected readonly version: number = 1; + readonly previewResource: URI = this.extUri.joinPath(this.syncPreviewFolder, 'profiles.json'); + readonly baseResource: URI = this.previewResource.with({ scheme: USER_DATA_SYNC_SCHEME, authority: 'base' }); + readonly localResource: URI = this.previewResource.with({ scheme: USER_DATA_SYNC_SCHEME, authority: 'local' }); + readonly remoteResource: URI = this.previewResource.with({ scheme: USER_DATA_SYNC_SCHEME, authority: 'remote' }); + readonly acceptedResource: URI = this.previewResource.with({ scheme: USER_DATA_SYNC_SCHEME, authority: 'accepted' }); + + constructor( + @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, + @IFileService fileService: IFileService, + @IEnvironmentService environmentService: IEnvironmentService, + @IStorageService storageService: IStorageService, + @IUserDataSyncStoreService userDataSyncStoreService: IUserDataSyncStoreService, + @IUserDataSyncBackupStoreService userDataSyncBackupStoreService: IUserDataSyncBackupStoreService, + @IUserDataSyncLogService logService: IUserDataSyncLogService, + @IConfigurationService configurationService: IConfigurationService, + @IUserDataSyncEnablementService userDataSyncEnablementService: IUserDataSyncEnablementService, + @ITelemetryService telemetryService: ITelemetryService, + @IUriIdentityService uriIdentityService: IUriIdentityService, + ) { + super(SyncResource.Profiles, fileService, environmentService, storageService, userDataSyncStoreService, userDataSyncBackupStoreService, userDataSyncEnablementService, telemetryService, logService, configurationService, uriIdentityService); + } + + protected async generateSyncPreview(remoteUserData: IRemoteUserData, lastSyncUserData: IRemoteUserData | null, isRemoteDataFromCurrentMachine: boolean): Promise { + const remoteProfiles: ISyncUserDataProfile[] | null = remoteUserData.syncData ? parseUserDataProfilesManifest(remoteUserData.syncData) : null; + const lastSyncProfiles: ISyncUserDataProfile[] | null = lastSyncUserData?.syncData ? parseUserDataProfilesManifest(lastSyncUserData.syncData) : null; + const localProfiles = this.getLocalUserDataProfiles(); + + const { local, remote } = merge(localProfiles, remoteProfiles, lastSyncProfiles, []); + const previewResult: IUserDataProfileManifestResourceMergeResult = { + local, remote, + content: lastSyncProfiles ? this.stringifyRemoteProfiles(lastSyncProfiles) : null, + localChange: local.added.length > 0 || local.removed.length > 0 || local.updated.length > 0 ? Change.Modified : Change.None, + remoteChange: remote !== null ? Change.Modified : Change.None, + }; + + const localContent = this.stringifyLocalProfiles(localProfiles, false); + return [{ + baseResource: this.baseResource, + baseContent: lastSyncProfiles ? this.stringifyRemoteProfiles(lastSyncProfiles) : null, + localResource: this.localResource, + localContent, + remoteResource: this.remoteResource, + remoteContent: remoteProfiles ? this.stringifyRemoteProfiles(remoteProfiles) : null, + remoteProfiles, + previewResource: this.previewResource, + previewResult, + localChange: previewResult.localChange, + remoteChange: previewResult.remoteChange, + acceptedResource: this.acceptedResource + }]; + } + + protected async hasRemoteChanged(lastSyncUserData: IRemoteUserData): Promise { + const lastSyncProfiles: ISyncUserDataProfile[] | null = lastSyncUserData?.syncData ? parseUserDataProfilesManifest(lastSyncUserData.syncData) : null; + const localProfiles = this.getLocalUserDataProfiles(); + const { remote } = merge(localProfiles, lastSyncProfiles, lastSyncProfiles, []); + return !!remote?.added.length || !!remote?.removed.length || !!remote?.updated.length; + } + + protected async getMergeResult(resourcePreview: IUserDataProfilesManifestResourcePreview, token: CancellationToken): Promise { + return { ...resourcePreview.previewResult, hasConflicts: false }; + } + + protected async getAcceptResult(resourcePreview: IUserDataProfilesManifestResourcePreview, resource: URI, content: string | null | undefined, token: CancellationToken): Promise { + /* Accept local resource */ + if (this.extUri.isEqual(resource, this.localResource)) { + return this.acceptLocal(resourcePreview); + } + + /* Accept remote resource */ + if (this.extUri.isEqual(resource, this.remoteResource)) { + return this.acceptRemote(resourcePreview); + } + + /* Accept preview resource */ + if (this.extUri.isEqual(resource, this.previewResource)) { + return resourcePreview.previewResult; + } + + throw new Error(`Invalid Resource: ${resource.toString()}`); + } + + private async acceptLocal(resourcePreview: IUserDataProfilesManifestResourcePreview): Promise { + const localProfiles = this.getLocalUserDataProfiles(); + const mergeResult = merge(localProfiles, null, null, []); + const { local, remote } = mergeResult; + return { + content: resourcePreview.localContent, + local, + remote, + localChange: local.added.length > 0 || local.removed.length > 0 || local.updated.length > 0 ? Change.Modified : Change.None, + remoteChange: remote !== null ? Change.Modified : Change.None, + }; + } + + private async acceptRemote(resourcePreview: IUserDataProfilesManifestResourcePreview): Promise { + const remoteProfiles: ISyncUserDataProfile[] = resourcePreview.remoteContent ? JSON.parse(resourcePreview.remoteContent) : null; + const lastSyncProfiles: ISyncUserDataProfile[] = []; + const localProfiles: IUserDataProfile[] = []; + for (const profile of this.getLocalUserDataProfiles()) { + const remoteProfile = remoteProfiles?.find(remoteProfile => remoteProfile.id === profile.id); + if (remoteProfile) { + lastSyncProfiles.push({ id: profile.id, name: profile.name, collection: remoteProfile.collection }); + localProfiles.push(profile); + } + } + if (remoteProfiles !== null) { + const mergeResult = merge(localProfiles, remoteProfiles, lastSyncProfiles, []); + const { local, remote } = mergeResult; + return { + content: resourcePreview.remoteContent, + local, + remote, + localChange: local.added.length > 0 || local.removed.length > 0 || local.updated.length > 0 ? Change.Modified : Change.None, + remoteChange: remote !== null ? Change.Modified : Change.None, + }; + } else { + return { + content: resourcePreview.remoteContent, + local: { added: [], removed: [], updated: [] }, + remote: null, + localChange: Change.None, + remoteChange: Change.None, + }; + } + } + + protected async applyResult(remoteUserData: IRemoteUserData, lastSyncUserData: IRemoteUserData | null, resourcePreviews: [IUserDataProfilesManifestResourcePreview, IUserDataProfileManifestResourceMergeResult][], force: boolean): Promise { + const { local, remote, localChange, remoteChange } = resourcePreviews[0][1]; + if (localChange === Change.None && remoteChange === Change.None) { + this.logService.info(`${this.syncResourceLogLabel}: No changes found during synchronizing profiles.`); + } + + if (localChange !== Change.None) { + await this.backupLocal(this.stringifyLocalProfiles(this.getLocalUserDataProfiles(), false)); + const promises: Promise[] = []; + for (const profile of local.added) { + promises.push(this.userDataProfilesService.createProfile(profile.id, profile.name)); + } + for (const profile of local.removed) { + promises.push(this.userDataProfilesService.removeProfile(profile)); + } + for (const profile of local.updated) { + const localProfile = this.userDataProfilesService.profiles.find(p => p.id === profile.id); + if (localProfile) { + promises.push(this.userDataProfilesService.updateProfile(localProfile, profile.name)); + } else { + this.logService.info(`${this.syncResourceLogLabel}: Could not find profile with id '${profile.id}' to update.`); + } + } + await Promise.all(promises); + } + + if (remoteChange !== Change.None) { + const remoteProfiles = resourcePreviews[0][0].remoteProfiles || []; + this.logService.trace(`${this.syncResourceLogLabel}: Updating remote profiles...`); + for (const profile of remote?.added || []) { + const collection = await this.userDataSyncStoreService.createCollection(this.syncHeaders); + remoteProfiles.push({ id: profile.id, name: profile.name, collection }); + } + for (const profile of remote?.removed || []) { + remoteProfiles.splice(remoteProfiles.findIndex(({ id }) => profile.id === id), 1); + } + for (const profile of remote?.updated || []) { + const profileToBeUpdated = remoteProfiles.find(({ id }) => profile.id === id); + if (profileToBeUpdated) { + remoteProfiles.splice(remoteProfiles.indexOf(profileToBeUpdated), 1, { id: profile.id, name: profile.name, collection: profileToBeUpdated.collection }); + } + } + remoteUserData = await this.updateRemoteUserData(this.stringifyRemoteProfiles(remoteProfiles), force ? null : remoteUserData.ref); + this.logService.info(`${this.syncResourceLogLabel}: Updated remote profiles.${remote?.added.length ? ` Added: ${JSON.stringify(remote.added.map(e => e.name))}.` : ''}${remote?.updated.length ? ` Updated: ${JSON.stringify(remote.updated.map(e => e.name))}.` : ''}${remote?.removed.length ? ` Removed: ${JSON.stringify(remote.removed.map(e => e.name))}.` : ''}`); + + for (const profile of remote?.removed || []) { + await this.userDataSyncStoreService.deleteCollection(profile.collection, this.syncHeaders); + } + } + + if (lastSyncUserData?.ref !== remoteUserData.ref) { + // update last sync + this.logService.trace(`${this.syncResourceLogLabel}: Updating last synchronized profiles...`); + await this.updateLastSyncUserData(remoteUserData); + this.logService.info(`${this.syncResourceLogLabel}: Updated last synchronized profiles.`); + } + } + + async hasLocalData(): Promise { + return this.getLocalUserDataProfiles().length > 0; + } + + async getAssociatedResources({ uri }: ISyncResourceHandle): Promise<{ resource: URI; comparableResource: URI }[]> { + return [{ resource: this.extUri.joinPath(uri, 'profiles.json'), comparableResource: UserDataProfilesManifestSynchroniser.PROFILES_DATA_URI }]; + } + + override async resolveContent(uri: URI): Promise { + if (this.extUri.isEqual(uri, UserDataProfilesManifestSynchroniser.PROFILES_DATA_URI)) { + return this.stringifyLocalProfiles(this.getLocalUserDataProfiles(), true); + } + + if (this.extUri.isEqual(this.remoteResource, uri) + || this.extUri.isEqual(this.baseResource, uri) + || this.extUri.isEqual(this.localResource, uri) + || this.extUri.isEqual(this.acceptedResource, uri) + ) { + const content = await this.resolvePreviewContent(uri); + return content ? toFormattedString(JSON.parse(content), {}) : content; + } + + let content = await super.resolveContent(uri); + if (content) { + return content; + } + + content = await super.resolveContent(this.extUri.dirname(uri)); + if (content) { + const syncData = this.parseSyncData(content); + if (syncData) { + switch (this.extUri.basename(uri)) { + case 'profiles.json': + return toFormattedString(parseUserDataProfilesManifest(syncData), {}); + } + } + } + + return null; + } + + private getLocalUserDataProfiles(): IUserDataProfile[] { + return this.userDataProfilesService.profiles.filter(p => !p.isDefault && !p.isTransient); + } + + private stringifyRemoteProfiles(profiles: ISyncUserDataProfile[]): string { + return JSON.stringify([...profiles].sort((a, b) => a.name.localeCompare(b.name))); + } + + private stringifyLocalProfiles(profiles: IUserDataProfile[], format: boolean): string { + const result = [...profiles].sort((a, b) => a.name.localeCompare(b.name)).map(p => ({ id: p.id, name: p.name })); + return format ? toFormattedString(result, {}) : JSON.stringify(result); + } + +} + +function parseUserDataProfilesManifest(syncData: ISyncData): ISyncUserDataProfile[] { + return JSON.parse(syncData.content); +} + + diff --git a/src/vs/platform/userDataSync/common/userDataSync.ts b/src/vs/platform/userDataSync/common/userDataSync.ts index 506557b371cd6..641a17dc89900 100644 --- a/src/vs/platform/userDataSync/common/userDataSync.ts +++ b/src/vs/platform/userDataSync/common/userDataSync.ts @@ -138,8 +138,9 @@ export const enum SyncResource { Tasks = 'tasks', Extensions = 'extensions', GlobalState = 'globalState', + Profiles = 'profiles', } -export const ALL_SYNC_RESOURCES: SyncResource[] = [SyncResource.Settings, SyncResource.Keybindings, SyncResource.Snippets, SyncResource.Tasks, SyncResource.Extensions, SyncResource.GlobalState]; +export const ALL_SYNC_RESOURCES: SyncResource[] = [SyncResource.Settings, SyncResource.Keybindings, SyncResource.Snippets, SyncResource.Tasks, SyncResource.Extensions, SyncResource.GlobalState, SyncResource.Profiles]; export function getLastSyncResourceUri(syncResource: SyncResource, environmentService: IEnvironmentService, extUri: IExtUri): URI { return extUri.joinPath(environmentService.userDataSyncHome, syncResource, `lastSync${syncResource}.json`); @@ -310,6 +311,12 @@ export namespace UserDataSyncError { // #region User Data Synchroniser +export interface ISyncUserDataProfile { + readonly id: string; + readonly collection: string; + readonly name: string; +} + export interface ISyncExtension { identifier: IExtensionIdentifier; preRelease?: boolean; diff --git a/src/vs/platform/userDataSync/common/userDataSyncService.ts b/src/vs/platform/userDataSync/common/userDataSyncService.ts index a32f5e187521a..fca3189343151 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncService.ts @@ -17,6 +17,7 @@ import { IHeaders } from 'vs/base/parts/request/common/request'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IExtensionGalleryService } from 'vs/platform/extensionManagement/common/extensionManagement'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IProductService } from 'vs/platform/product/common/productService'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IUserDataProfile, IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; @@ -26,6 +27,7 @@ import { KeybindingsSynchroniser } from 'vs/platform/userDataSync/common/keybind import { SettingsSynchroniser } from 'vs/platform/userDataSync/common/settingsSync'; import { SnippetsSynchroniser } from 'vs/platform/userDataSync/common/snippetsSync'; import { TasksSynchroniser } from 'vs/platform/userDataSync/common/tasksSync'; +import { UserDataProfilesManifestSynchroniser } from 'vs/platform/userDataSync/common/userDataProfilesManifestSync'; import { ALL_SYNC_RESOURCES, Change, createSyncHeaders, IManualSyncTask, IResourcePreview, ISyncResourceHandle, ISyncResourcePreview, ISyncTask, IUserDataManifest, IUserDataSyncConfiguration, IUserDataSyncEnablementService, IUserDataSynchroniser, IUserDataSyncLogService, IUserDataSyncService, IUserDataSyncStoreManagementService, IUserDataSyncStoreService, MergeState, SyncResource, SyncStatus, UserDataSyncError, UserDataSyncErrorCode, UserDataSyncStoreError, USER_DATA_SYNC_CONFIGURATION_SCOPE } from 'vs/platform/userDataSync/common/userDataSync'; type SyncErrorClassification = { @@ -380,7 +382,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ const profileSynchronizers = new Map(); for (const profile of [this.userDataProfilesService.defaultProfile]) { const disposables = new DisposableStore(); - const profileSynchronizer = disposables.add(profile.isDefault ? this.instantiationService.createInstance(DefaultProfileSynchronizer) : this.instantiationService.createInstance(ProfileSynchronizer, profile)); + const profileSynchronizer = disposables.add(this.instantiationService.createInstance(ProfileSynchronizer, profile)); disposables.add(profileSynchronizer.onDidChangeStatus(e => this.setStatus(e))); disposables.add(profileSynchronizer.onDidChangeConflicts(e => this.setConflicts(e))); disposables.add(profileSynchronizer.onDidChangeLocal(e => this._onDidChangeLocal.fire(e))); @@ -757,15 +759,27 @@ class ProfileSynchronizer extends Disposable { readonly onDidChangeConflicts: Event<[SyncResource, IResourcePreview[]][]> = this._onDidChangeConflicts.event; constructor( - protected profile: IUserDataProfile, + private profile: IUserDataProfile, @IUserDataSyncEnablementService private readonly userDataSyncEnablementService: IUserDataSyncEnablementService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, @IUserDataSyncStoreManagementService private readonly userDataSyncStoreManagementService: IUserDataSyncStoreManagementService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IUserDataSyncLogService private readonly logService: IUserDataSyncLogService, + @IProductService private readonly productService: IProductService, + @IUserDataProfilesService userDataProfilesService: IUserDataProfilesService, ) { super(); + if (this.profile.isDefault) { + this._register(userDataProfilesService.onDidChangeProfiles(() => { + if ((userDataProfilesService.defaultProfile.extensionsResource && !this.profile.extensionsResource) || + (!userDataProfilesService.defaultProfile.extensionsResource && this.profile.extensionsResource)) { + this.deRegisterSynchronizer(SyncResource.Extensions); + this.profile = userDataProfilesService.defaultProfile; + this.registerSynchronizer(SyncResource.Extensions); + } + })); + } this._register(userDataSyncEnablementService.onDidChangeResourceEnablement(([syncResource, enablement]) => this.onDidChangeResourceEnablement(syncResource, enablement))); this._register(toDisposable(() => this._enabled.splice(0, this._enabled.length).forEach(([, , disposable]) => disposable.dispose()))); for (const syncResource of ALL_SYNC_RESOURCES) { @@ -791,6 +805,15 @@ class ProfileSynchronizer extends Disposable { this.logService.info('Skipping extensions sync because gallery is not configured'); return; } + if (syncResource === SyncResource.Profiles) { + if (!this.profile.isDefault) { + return; + } + if (!this.productService.enableSyncingProfiles) { + this.logService.debug('Skipping profiles sync'); + return; + } + } const disposables = new DisposableStore(); const synchronizer = disposables.add(this.createSynchronizer(syncResource)); disposables.add(synchronizer.onDidChangeStatus(() => this.updateStatus())); @@ -826,6 +849,7 @@ class ProfileSynchronizer extends Disposable { case SyncResource.Tasks: return this.instantiationService.createInstance(TasksSynchroniser, this.profile.tasksResource); case SyncResource.GlobalState: return this.instantiationService.createInstance(GlobalStateSynchroniser, this.profile); case SyncResource.Extensions: return this.instantiationService.createInstance(ExtensionsSynchroniser, this.profile.extensionsResource); + case SyncResource.Profiles: return this.instantiationService.createInstance(UserDataProfilesManifestSynchroniser); } } @@ -928,41 +952,18 @@ class ProfileSynchronizer extends Disposable { private getOrder(syncResource: SyncResource): number { switch (syncResource) { - case SyncResource.Settings: return 0; - case SyncResource.Keybindings: return 1; - case SyncResource.Snippets: return 2; - case SyncResource.Tasks: return 3; - case SyncResource.GlobalState: return 4; - case SyncResource.Extensions: return 5; + case SyncResource.Profiles: return 0; + case SyncResource.Settings: return 1; + case SyncResource.Keybindings: return 2; + case SyncResource.Snippets: return 3; + case SyncResource.Tasks: return 4; + case SyncResource.GlobalState: return 5; + case SyncResource.Extensions: return 6; } } } -class DefaultProfileSynchronizer extends ProfileSynchronizer { - - constructor( - @IUserDataProfilesService userDataProfilesService: IUserDataProfilesService, - @IUserDataSyncEnablementService userDataSyncEnablementService: IUserDataSyncEnablementService, - @IInstantiationService instantiationService: IInstantiationService, - @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, - @IUserDataSyncStoreManagementService userDataSyncStoreManagementService: IUserDataSyncStoreManagementService, - @ITelemetryService telemetryService: ITelemetryService, - @IUserDataSyncLogService logService: IUserDataSyncLogService, - ) { - super(userDataProfilesService.defaultProfile, userDataSyncEnablementService, instantiationService, extensionGalleryService, userDataSyncStoreManagementService, telemetryService, logService); - this._register(userDataProfilesService.onDidChangeProfiles(() => { - if ((userDataProfilesService.defaultProfile.extensionsResource && !this.profile.extensionsResource) || - (!userDataProfilesService.defaultProfile.extensionsResource && this.profile.extensionsResource)) { - this.deRegisterSynchronizer(SyncResource.Extensions); - this.profile = userDataProfilesService.defaultProfile; - this.registerSynchronizer(SyncResource.Extensions); - } - })); - } - -} - function canBailout(e: any): boolean { if (e instanceof UserDataSyncError) { switch (e.code) { diff --git a/src/vs/platform/userDataSync/test/common/userDataAutoSyncService.test.ts b/src/vs/platform/userDataSync/test/common/userDataAutoSyncService.test.ts index cc94faefa1afb..bc31f2612dddd 100644 --- a/src/vs/platform/userDataSync/test/common/userDataAutoSyncService.test.ts +++ b/src/vs/platform/userDataSync/test/common/userDataAutoSyncService.test.ts @@ -149,6 +149,8 @@ suite('UserDataAutoSyncService', () => { { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, // Machines { type: 'GET', url: `${target.url}/v1/resource/machines/latest`, headers: {} }, + // Profiles + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: {} }, // Settings { type: 'GET', url: `${target.url}/v1/resource/settings/latest`, headers: {} }, { type: 'POST', url: `${target.url}/v1/resource/settings`, headers: { 'If-Match': '0' } }, diff --git a/src/vs/platform/userDataSync/test/common/userDataProfilesManifestMerge.test.ts b/src/vs/platform/userDataSync/test/common/userDataProfilesManifestMerge.test.ts new file mode 100644 index 0000000000000..9df2650b0e702 --- /dev/null +++ b/src/vs/platform/userDataSync/test/common/userDataProfilesManifestMerge.test.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from 'vs/base/common/uri'; +import { IUserDataProfile, toUserDataProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; +import { merge } from 'vs/platform/userDataSync/common/userDataProfilesManifestMerge'; +import { ISyncUserDataProfile } from 'vs/platform/userDataSync/common/userDataSync'; + +suite('UserDataProfilesManifestMerge', () => { + + test('merge returns local profiles if remote does not exist', () => { + const localProfiles: IUserDataProfile[] = [ + toUserDataProfile('1', '1', URI.file('1')), + toUserDataProfile('2', '2', URI.file('2')), + ]; + + const actual = merge(localProfiles, null, null, []); + + assert.deepStrictEqual(actual.local.added, []); + assert.deepStrictEqual(actual.local.removed, []); + assert.deepStrictEqual(actual.local.updated, []); + assert.deepStrictEqual(actual.remote?.added, localProfiles); + assert.deepStrictEqual(actual.remote?.updated, []); + assert.deepStrictEqual(actual.remote?.removed, []); + }); + + test('merge returns local profiles if remote does not exist with ignored profiles', () => { + const localProfiles: IUserDataProfile[] = [ + toUserDataProfile('1', '1', URI.file('1')), + toUserDataProfile('2', '2', URI.file('2')), + ]; + + const actual = merge(localProfiles, null, null, ['2']); + + assert.deepStrictEqual(actual.local.added, []); + assert.deepStrictEqual(actual.local.removed, []); + assert.deepStrictEqual(actual.local.updated, []); + assert.deepStrictEqual(actual.remote?.added, [localProfiles[0]]); + assert.deepStrictEqual(actual.remote?.updated, []); + assert.deepStrictEqual(actual.remote?.removed, []); + }); + + test('merge local and remote profiles when there is no base', () => { + const localProfiles: IUserDataProfile[] = [ + toUserDataProfile('1', '1', URI.file('1')), + toUserDataProfile('2', '2', URI.file('2')), + ]; + const remoteProfiles: ISyncUserDataProfile[] = [ + { id: '1', name: 'changed', collection: '1' }, + { id: '3', name: '3', collection: '3' }, + ]; + + const actual = merge(localProfiles, remoteProfiles, null, []); + + assert.deepStrictEqual(actual.local.added, [remoteProfiles[1]]); + assert.deepStrictEqual(actual.local.removed, []); + assert.deepStrictEqual(actual.local.updated, [remoteProfiles[0]]); + assert.deepStrictEqual(actual.remote?.added, [localProfiles[1]]); + assert.deepStrictEqual(actual.remote?.updated, []); + assert.deepStrictEqual(actual.remote?.removed, []); + }); + + test('merge local and remote profiles when there is base', () => { + const localProfiles: IUserDataProfile[] = [ + toUserDataProfile('1', 'changed 1', URI.file('1')), + toUserDataProfile('3', '3', URI.file('3')), + toUserDataProfile('4', 'changed local', URI.file('4')), + toUserDataProfile('5', '5', URI.file('5')), + toUserDataProfile('6', '6', URI.file('6')), + toUserDataProfile('8', '8', URI.file('8')), + ]; + const base: ISyncUserDataProfile[] = [ + { id: '1', name: '1', collection: '1' }, + { id: '2', name: '2', collection: '2' }, + { id: '3', name: '3', collection: '3' }, + { id: '4', name: '4', collection: '4' }, + { id: '5', name: '5', collection: '5' }, + { id: '6', name: '6', collection: '6' }, + ]; + const remoteProfiles: ISyncUserDataProfile[] = [ + { id: '1', name: '1', collection: '1' }, + { id: '2', name: '2', collection: '2' }, + { id: '3', name: 'changed 3', collection: '3' }, + { id: '4', name: 'changed remote', collection: '4' }, + { id: '5', name: '5', collection: '5' }, + { id: '7', name: '7', collection: '7' }, + ]; + + const actual = merge(localProfiles, remoteProfiles, base, []); + + assert.deepStrictEqual(actual.local.added, [remoteProfiles[5]]); + assert.deepStrictEqual(actual.local.removed, [localProfiles[4]]); + assert.deepStrictEqual(actual.local.updated, [remoteProfiles[2], remoteProfiles[3]]); + assert.deepStrictEqual(actual.remote?.added, [localProfiles[5]]); + assert.deepStrictEqual(actual.remote?.updated, [localProfiles[0]]); + assert.deepStrictEqual(actual.remote?.removed, [remoteProfiles[1]]); + }); + + test('merge local and remote profiles when there is base with ignored profiles', () => { + const localProfiles: IUserDataProfile[] = [ + toUserDataProfile('1', 'changed 1', URI.file('1')), + toUserDataProfile('3', '3', URI.file('3')), + toUserDataProfile('4', 'changed local', URI.file('4')), + toUserDataProfile('5', '5', URI.file('5')), + toUserDataProfile('6', '6', URI.file('6')), + toUserDataProfile('8', '8', URI.file('8')), + ]; + const base: ISyncUserDataProfile[] = [ + { id: '1', name: '1', collection: '1' }, + { id: '2', name: '2', collection: '2' }, + { id: '3', name: '3', collection: '3' }, + { id: '4', name: '4', collection: '4' }, + { id: '5', name: '5', collection: '5' }, + { id: '6', name: '6', collection: '6' }, + ]; + const remoteProfiles: ISyncUserDataProfile[] = [ + { id: '1', name: '1', collection: '1' }, + { id: '2', name: '2', collection: '2' }, + { id: '3', name: 'changed 3', collection: '3' }, + { id: '4', name: 'changed remote', collection: '4' }, + { id: '5', name: '5', collection: '5' }, + { id: '7', name: '7', collection: '7' }, + ]; + + const actual = merge(localProfiles, remoteProfiles, base, ['4', '8']); + + assert.deepStrictEqual(actual.local.added, [remoteProfiles[5]]); + assert.deepStrictEqual(actual.local.removed, [localProfiles[4]]); + assert.deepStrictEqual(actual.local.updated, [remoteProfiles[2]]); + assert.deepStrictEqual(actual.remote?.added, []); + assert.deepStrictEqual(actual.remote?.updated, [localProfiles[0]]); + assert.deepStrictEqual(actual.remote?.removed, [remoteProfiles[1]]); + }); + + test('merge when there are no remote changes', () => { + const localProfiles: IUserDataProfile[] = [ + toUserDataProfile('1', '1', URI.file('1')), + ]; + const base: ISyncUserDataProfile[] = [ + { id: '1', name: '1', collection: '1' }, + ]; + const remoteProfiles: ISyncUserDataProfile[] = [ + { id: '1', name: 'name changed', collection: '1' }, + ]; + + const actual = merge(localProfiles, remoteProfiles, base, []); + + assert.deepStrictEqual(actual.local.added, []); + assert.deepStrictEqual(actual.local.removed, []); + assert.deepStrictEqual(actual.local.updated, [remoteProfiles[0]]); + assert.strictEqual(actual.remote, null); + }); + + test('merge when there are no local and remote changes', () => { + const localProfiles: IUserDataProfile[] = [ + toUserDataProfile('1', '1', URI.file('1')), + ]; + const base: ISyncUserDataProfile[] = [ + { id: '1', name: '1', collection: '1' }, + ]; + const remoteProfiles: ISyncUserDataProfile[] = [ + { id: '1', name: '1', collection: '1' }, + ]; + + const actual = merge(localProfiles, remoteProfiles, base, []); + + assert.deepStrictEqual(actual.local.added, []); + assert.deepStrictEqual(actual.local.removed, []); + assert.deepStrictEqual(actual.local.updated, []); + assert.strictEqual(actual.remote, null); + }); + +}); diff --git a/src/vs/platform/userDataSync/test/common/userDataProfilesManifestSync.test.ts b/src/vs/platform/userDataSync/test/common/userDataProfilesManifestSync.test.ts new file mode 100644 index 0000000000000..3348f6d23a942 --- /dev/null +++ b/src/vs/platform/userDataSync/test/common/userDataProfilesManifestSync.test.ts @@ -0,0 +1,216 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; +import { UserDataProfilesManifestSynchroniser } from 'vs/platform/userDataSync/common/userDataProfilesManifestSync'; +import { ISyncData, ISyncUserDataProfile, IUserDataSyncStoreService, SyncResource, SyncStatus } from 'vs/platform/userDataSync/common/userDataSync'; +import { UserDataSyncClient, UserDataSyncTestServer } from 'vs/platform/userDataSync/test/common/userDataSyncClient'; + +suite('UserDataProfilesManifestSync', () => { + + const disposableStore = new DisposableStore(); + const server = new UserDataSyncTestServer(); + let testClient: UserDataSyncClient; + let client2: UserDataSyncClient; + + let testObject: UserDataProfilesManifestSynchroniser; + + setup(async () => { + testClient = disposableStore.add(new UserDataSyncClient(server)); + await testClient.setUp(true); + testObject = testClient.getSynchronizer(SyncResource.Profiles) as UserDataProfilesManifestSynchroniser; + disposableStore.add(toDisposable(() => testClient.instantiationService.get(IUserDataSyncStoreService).clear())); + + client2 = disposableStore.add(new UserDataSyncClient(server)); + await client2.setUp(true); + }); + + teardown(() => disposableStore.clear()); + + test('when profiles does not exist', async () => { + assert.deepStrictEqual(await testObject.getLastSyncUserData(), null); + let manifest = await testClient.manifest(); + server.reset(); + await testObject.sync(manifest); + + assert.deepStrictEqual(server.requests, [ + { type: 'GET', url: `${server.url}/v1/resource/${testObject.resource}/latest`, headers: {} }, + ]); + + const lastSyncUserData = await testObject.getLastSyncUserData(); + const remoteUserData = await testObject.getRemoteUserData(null); + assert.deepStrictEqual(lastSyncUserData!.ref, remoteUserData.ref); + assert.deepStrictEqual(lastSyncUserData!.syncData, remoteUserData.syncData); + assert.strictEqual(lastSyncUserData!.syncData, null); + + manifest = await testClient.manifest(); + server.reset(); + await testObject.sync(manifest); + assert.deepStrictEqual(server.requests, []); + + manifest = await testClient.manifest(); + server.reset(); + await testObject.sync(manifest); + assert.deepStrictEqual(server.requests, []); + }); + + test('when profile is created after first sync', async () => { + await testObject.sync(await testClient.manifest()); + await testClient.instantiationService.get(IUserDataProfilesService).createProfile('1', '1'); + + let lastSyncUserData = await testObject.getLastSyncUserData(); + const manifest = await testClient.manifest(); + server.reset(); + await testObject.sync(manifest); + + assert.deepStrictEqual(server.requests, [ + { type: 'POST', url: `${server.url}/v1/collection`, headers: {} }, + { type: 'POST', url: `${server.url}/v1/resource/${testObject.resource}`, headers: { 'If-Match': lastSyncUserData?.ref } }, + ]); + + lastSyncUserData = await testObject.getLastSyncUserData(); + const remoteUserData = await testObject.getRemoteUserData(null); + assert.deepStrictEqual(lastSyncUserData!.ref, remoteUserData.ref); + assert.deepStrictEqual(lastSyncUserData!.syncData, remoteUserData.syncData); + assert.deepStrictEqual(JSON.parse(lastSyncUserData!.syncData!.content), [{ 'name': '1', 'id': '1', 'collection': '1' }]); + }); + + test('first time sync - outgoing to server (no state)', async () => { + await testClient.instantiationService.get(IUserDataProfilesService).createProfile('1', '1'); + + await testObject.sync(await testClient.manifest()); + assert.strictEqual(testObject.status, SyncStatus.Idle); + assert.deepStrictEqual(testObject.conflicts, []); + + const { content } = await testClient.read(testObject.resource); + assert.ok(content !== null); + assert.deepStrictEqual(JSON.parse(JSON.parse(content).content), [{ 'name': '1', 'id': '1', 'collection': '1' }]); + }); + + test('first time sync - incoming from server (no state)', async () => { + await client2.instantiationService.get(IUserDataProfilesService).createProfile('1', 'name 1'); + await client2.sync(); + + await testObject.sync(await testClient.manifest()); + assert.strictEqual(testObject.status, SyncStatus.Idle); + assert.deepStrictEqual(testObject.conflicts, []); + + const profiles = getLocalProfiles(testClient); + assert.deepStrictEqual(profiles, [{ id: '1', name: 'name 1' }]); + }); + + test('first time sync when profiles exists', async () => { + await client2.instantiationService.get(IUserDataProfilesService).createProfile('1', 'name 1'); + await client2.sync(); + + await testClient.instantiationService.get(IUserDataProfilesService).createProfile('2', 'name 2'); + await testObject.sync(await testClient.manifest()); + assert.strictEqual(testObject.status, SyncStatus.Idle); + assert.deepStrictEqual(testObject.conflicts, []); + + const profiles = getLocalProfiles(testClient); + assert.deepStrictEqual(profiles, [{ id: '1', name: 'name 1' }, { id: '2', name: 'name 2' }]); + + const { content } = await testClient.read(testObject.resource); + assert.ok(content !== null); + const actual = parseRemoteProfiles(content!); + assert.deepStrictEqual(actual, [{ id: '1', name: 'name 1', collection: '1' }, { id: '2', name: 'name 2', collection: '2' }]); + }); + + test('first time sync when storage exists - has conflicts', async () => { + await client2.instantiationService.get(IUserDataProfilesService).createProfile('1', 'name 1'); + await client2.sync(); + + await testClient.instantiationService.get(IUserDataProfilesService).createProfile('1', 'name 2'); + await testObject.sync(await testClient.manifest()); + + assert.strictEqual(testObject.status, SyncStatus.Idle); + assert.deepStrictEqual(testObject.conflicts, []); + + const profiles = getLocalProfiles(testClient); + assert.deepStrictEqual(profiles, [{ id: '1', name: 'name 1' }]); + + const { content } = await testClient.read(testObject.resource); + assert.ok(content !== null); + const actual = parseRemoteProfiles(content!); + assert.deepStrictEqual(actual, [{ id: '1', name: 'name 1', collection: '1' }]); + }); + + test('sync adding a profile', async () => { + await testClient.instantiationService.get(IUserDataProfilesService).createProfile('1', 'name 1'); + await testObject.sync(await testClient.manifest()); + await client2.sync(); + + await testClient.instantiationService.get(IUserDataProfilesService).createProfile('2', 'name 2'); + await testObject.sync(await testClient.manifest()); + assert.strictEqual(testObject.status, SyncStatus.Idle); + assert.deepStrictEqual(testObject.conflicts, []); + assert.deepStrictEqual(getLocalProfiles(testClient), [{ id: '1', name: 'name 1' }, { id: '2', name: 'name 2' }]); + + await client2.sync(); + assert.deepStrictEqual(getLocalProfiles(client2), [{ id: '1', name: 'name 1' }, { id: '2', name: 'name 2' }]); + + const { content } = await testClient.read(testObject.resource); + assert.ok(content !== null); + const actual = parseRemoteProfiles(content!); + assert.deepStrictEqual(actual, [{ id: '1', name: 'name 1', collection: '1' }, { id: '2', name: 'name 2', collection: '2' }]); + }); + + test('sync updating a profile', async () => { + const profile = await testClient.instantiationService.get(IUserDataProfilesService).createProfile('1', 'name 1'); + await testObject.sync(await testClient.manifest()); + await client2.sync(); + + await testClient.instantiationService.get(IUserDataProfilesService).updateProfile(profile, 'name 2'); + await testObject.sync(await testClient.manifest()); + assert.strictEqual(testObject.status, SyncStatus.Idle); + assert.deepStrictEqual(testObject.conflicts, []); + assert.deepStrictEqual(getLocalProfiles(testClient), [{ id: '1', name: 'name 2' }]); + + await client2.sync(); + assert.deepStrictEqual(getLocalProfiles(client2), [{ id: '1', name: 'name 2' }]); + + const { content } = await testClient.read(testObject.resource); + assert.ok(content !== null); + const actual = parseRemoteProfiles(content!); + assert.deepStrictEqual(actual, [{ id: '1', name: 'name 2', collection: '1' }]); + }); + + test('sync removing a profile', async () => { + const profile = await testClient.instantiationService.get(IUserDataProfilesService).createProfile('1', 'name 1'); + await testClient.instantiationService.get(IUserDataProfilesService).createProfile('2', 'name 2'); + await testObject.sync(await testClient.manifest()); + await client2.sync(); + + testClient.instantiationService.get(IUserDataProfilesService).removeProfile(profile); + await testObject.sync(await testClient.manifest()); + assert.strictEqual(testObject.status, SyncStatus.Idle); + assert.deepStrictEqual(testObject.conflicts, []); + assert.deepStrictEqual(getLocalProfiles(testClient), [{ id: '2', name: 'name 2' }]); + + await client2.sync(); + assert.deepStrictEqual(getLocalProfiles(client2), [{ id: '2', name: 'name 2' }]); + + const { content } = await testClient.read(testObject.resource); + assert.ok(content !== null); + const actual = parseRemoteProfiles(content!); + assert.deepStrictEqual(actual, [{ id: '2', name: 'name 2', collection: '2' }]); + }); + + function parseRemoteProfiles(content: string): ISyncUserDataProfile[] { + const syncData: ISyncData = JSON.parse(content); + return JSON.parse(syncData.content); + } + + function getLocalProfiles(client: UserDataSyncClient): { id: string; name: string }[] { + return client.instantiationService.get(IUserDataProfilesService).profiles + .slice(1).sort((a, b) => a.name.localeCompare(b.name)) + .map(profile => ({ id: profile.id, name: profile.name })); + } + + +}); diff --git a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts index 3a2c690198664..ab812b50a8436 100644 --- a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts +++ b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts @@ -41,7 +41,7 @@ import { IUserDataSyncMachinesService, UserDataSyncMachinesService } from 'vs/pl import { UserDataSyncEnablementService } from 'vs/platform/userDataSync/common/userDataSyncEnablementService'; import { UserDataSyncService } from 'vs/platform/userDataSync/common/userDataSyncService'; import { UserDataSyncStoreManagementService, UserDataSyncStoreService } from 'vs/platform/userDataSync/common/userDataSyncStoreService'; -import { IUserDataProfile, IUserDataProfilesService, UserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; +import { InMemoryUserDataProfilesService, IUserDataProfile, IUserDataProfilesService } from 'vs/platform/userDataProfile/common/userDataProfile'; import { NullPolicyService } from 'vs/platform/policy/common/policy'; import { IUserDataSyncProfilesStorageService } from 'vs/platform/userDataSync/common/userDataSyncProfilesStorageService'; import { TestUserDataSyncProfilesStorageService } from 'vs/platform/userDataSync/test/common/userDataSyncProfilesStorageService.test'; @@ -77,7 +77,8 @@ export class UserDataSyncClient extends Disposable { insidersUrl: this.testServer.url, canSwitch: false, authenticationProviders: { 'test': { scopes: [] } } - } + }, + enableSyncingProfiles: true } }); @@ -88,7 +89,9 @@ export class UserDataSyncClient extends Disposable { const uriIdentityService = this.instantiationService.createInstance(UriIdentityService); this.instantiationService.stub(IUriIdentityService, uriIdentityService); - const userDataProfilesService = this.instantiationService.stub(IUserDataProfilesService, new UserDataProfilesService(environmentService, fileService, uriIdentityService, logService)); + const userDataProfilesService = new InMemoryUserDataProfilesService(environmentService, fileService, uriIdentityService, logService); + this.instantiationService.stub(IUserDataProfilesService, userDataProfilesService); + userDataProfilesService.setEnablement(true); const storageService = new TestStorageService(userDataProfilesService.defaultProfile); this.instantiationService.stub(IStorageService, this._register(storageService)); @@ -178,6 +181,7 @@ export class UserDataSyncTestServer implements IRequestService { reset(): void { this._requests = []; this._responses = []; this._requestsWithAllHeaders = []; } private manifestRef = 0; + private collectionCounter = 1; constructor(private readonly rateLimit = Number.MAX_SAFE_INTEGER, private readonly retryAfter?: number) { } @@ -219,9 +223,12 @@ export class UserDataSyncTestServer implements IRequestService { if (options.type === 'DELETE' && segments.length === 1 && segments[0] === 'resource') { return this.clear(options.headers); } - if (options.type === 'DELETE' && segments.length === 1 && segments[0] === 'collection') { + if (options.type === 'DELETE' && segments[0] === 'collection') { return this.toResponse(204); } + if (options.type === 'POST' && segments.length === 1 && segments[0] === 'collection') { + return this.toResponse(200, {}, `${this.collectionCounter++}`); + } return this.toResponse(501); } @@ -270,6 +277,7 @@ export class UserDataSyncTestServer implements IRequestService { async clear(headers?: IHeaders): Promise { this.data.clear(); this.session = null; + this.collectionCounter = 1; return this.toResponse(204); } diff --git a/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts b/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts index 180502a1b2ed2..0074c9876817d 100644 --- a/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts +++ b/src/vs/platform/userDataSync/test/common/userDataSyncService.test.ts @@ -32,6 +32,8 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ // Manifest { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + // Profiles + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: {} }, // Settings { type: 'GET', url: `${target.url}/v1/resource/settings/latest`, headers: {} }, { type: 'POST', url: `${target.url}/v1/resource/settings`, headers: { 'If-Match': '0' } }, @@ -67,6 +69,8 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ // Manifest { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + // Profiles + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: {} }, // Keybindings { type: 'GET', url: `${target.url}/v1/resource/keybindings/latest`, headers: {} }, { type: 'POST', url: `${target.url}/v1/resource/keybindings`, headers: { 'If-Match': '0' } }, @@ -98,6 +102,8 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ // Manifest { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + // Profiles + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: {} }, // Settings { type: 'GET', url: `${target.url}/v1/resource/settings/latest`, headers: {} }, // Keybindings @@ -133,6 +139,7 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: {} }, { type: 'GET', url: `${target.url}/v1/resource/settings/latest`, headers: {} }, { type: 'GET', url: `${target.url}/v1/resource/keybindings/latest`, headers: {} }, { type: 'GET', url: `${target.url}/v1/resource/snippets/latest`, headers: {} }, @@ -157,6 +164,7 @@ suite('UserDataSyncService', () => { const fileService = testClient.instantiationService.get(IFileService); const environmentService = testClient.instantiationService.get(IEnvironmentService); const userDataProfilesService = testClient.instantiationService.get(IUserDataProfilesService); + await userDataProfilesService.createNamedProfile('1'); await fileService.writeFile(userDataProfilesService.defaultProfile.settingsResource, VSBuffer.fromString(JSON.stringify({ 'editor.fontSize': 14 }))); await fileService.writeFile(userDataProfilesService.defaultProfile.keybindingsResource, VSBuffer.fromString(JSON.stringify([{ 'command': 'abcd', 'key': 'cmd+c' }]))); await fileService.writeFile(environmentService.argvResource, VSBuffer.fromString(JSON.stringify({ 'locale': 'de' }))); @@ -170,6 +178,9 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: {} }, + { type: 'POST', url: `${target.url}/v1/collection`, headers: {} }, + { type: 'POST', url: `${target.url}/v1/resource/profiles`, headers: { 'If-Match': '0' } }, { type: 'GET', url: `${target.url}/v1/resource/settings/latest`, headers: {} }, { type: 'POST', url: `${target.url}/v1/resource/settings`, headers: { 'If-Match': '1' } }, { type: 'GET', url: `${target.url}/v1/resource/keybindings/latest`, headers: {} }, @@ -216,6 +227,7 @@ suite('UserDataSyncService', () => { const fileService = client.instantiationService.get(IFileService); const environmentService = client.instantiationService.get(IEnvironmentService); const userDataProfilesService = client.instantiationService.get(IUserDataProfilesService); + await userDataProfilesService.createNamedProfile('1'); await fileService.writeFile(userDataProfilesService.defaultProfile.settingsResource, VSBuffer.fromString(JSON.stringify({ 'editor.fontSize': 14 }))); await fileService.writeFile(userDataProfilesService.defaultProfile.keybindingsResource, VSBuffer.fromString(JSON.stringify([{ 'command': 'abcd', 'key': 'cmd+c' }]))); await fileService.writeFile(joinPath(userDataProfilesService.defaultProfile.snippetsHome, 'html.json'), VSBuffer.fromString(`{}`)); @@ -227,6 +239,9 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ // Manifest { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + // Profiles + { type: 'POST', url: `${target.url}/v1/collection`, headers: {} }, + { type: 'POST', url: `${target.url}/v1/resource/profiles`, headers: { 'If-Match': '0' } }, // Settings { type: 'POST', url: `${target.url}/v1/resource/settings`, headers: { 'If-Match': '1' } }, // Keybindings @@ -291,6 +306,7 @@ suite('UserDataSyncService', () => { const fileService = client.instantiationService.get(IFileService); const environmentService = client.instantiationService.get(IEnvironmentService); const userDataProfilesService = client.instantiationService.get(IUserDataProfilesService); + await userDataProfilesService.createNamedProfile('1'); await fileService.writeFile(userDataProfilesService.defaultProfile.settingsResource, VSBuffer.fromString(JSON.stringify({ 'editor.fontSize': 14 }))); await fileService.writeFile(userDataProfilesService.defaultProfile.keybindingsResource, VSBuffer.fromString(JSON.stringify([{ 'command': 'abcd', 'key': 'cmd+c' }]))); await fileService.writeFile(joinPath(userDataProfilesService.defaultProfile.snippetsHome, 'html.json'), VSBuffer.fromString(`{ "a": "changed" }`)); @@ -304,6 +320,8 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ // Manifest { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + // Profiles + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: { 'If-None-Match': '0' } }, // Settings { type: 'GET', url: `${target.url}/v1/resource/settings/latest`, headers: { 'If-None-Match': '1' } }, // Keybindings @@ -356,6 +374,8 @@ suite('UserDataSyncService', () => { assert.deepStrictEqual(target.requests, [ // Manifest { type: 'GET', url: `${target.url}/v1/manifest`, headers: {} }, + // Profiles + { type: 'GET', url: `${target.url}/v1/resource/profiles/latest`, headers: {} }, // Settings { type: 'GET', url: `${target.url}/v1/resource/settings/latest`, headers: {} }, { type: 'POST', url: `${target.url}/v1/resource/settings`, headers: { 'If-Match': '0' } }, @@ -391,7 +411,7 @@ suite('UserDataSyncService', () => { await (await testObject.createSyncTask(null)).run(); disposable.dispose(); - assert.deepStrictEqual(actualStatuses, [SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle]); + assert.deepStrictEqual(actualStatuses, [SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle, SyncStatus.Syncing, SyncStatus.Idle]); }); test('test sync conflicts status', async () => { diff --git a/src/vs/workbench/services/userDataSync/common/userDataSync.ts b/src/vs/workbench/services/userDataSync/common/userDataSync.ts index a6e289f706dc0..de4ffa9ac3e53 100644 --- a/src/vs/workbench/services/userDataSync/common/userDataSync.ts +++ b/src/vs/workbench/services/userDataSync/common/userDataSync.ts @@ -77,6 +77,7 @@ export function getSyncAreaLabel(source: SyncResource): string { case SyncResource.Tasks: return localize('tasks', "User Tasks"); case SyncResource.Extensions: return localize('extensions', "Extensions"); case SyncResource.GlobalState: return localize('ui state label', "UI State"); + case SyncResource.Profiles: return localize('settings profiles', "Settings Profiles"); } }