diff --git a/src/vs/platform/userDataSync/common/globalStateMerge.ts b/src/vs/platform/userDataSync/common/globalStateMerge.ts new file mode 100644 index 00000000000..f4520a76a38 --- /dev/null +++ b/src/vs/platform/userDataSync/common/globalStateMerge.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as objects from 'vs/base/common/objects'; +import { IGlobalState } from 'vs/platform/userDataSync/common/userDataSync'; +import { IStringDictionary } from 'vs/base/common/collections'; +import { values } from 'vs/base/common/map'; + +export function merge(localGloablState: IGlobalState, remoteGlobalState: IGlobalState | null, lastSyncGlobalState: IGlobalState | null): { local?: IGlobalState, remote?: IGlobalState } { + if (!remoteGlobalState) { + return { remote: localGloablState }; + } + + const { local: localArgv, remote: remoteArgv } = doMerge(localGloablState.argv, remoteGlobalState.argv, lastSyncGlobalState ? lastSyncGlobalState.argv : null); + const { local: localStorage, remote: remoteStorage } = doMerge(localGloablState.storage, remoteGlobalState.storage, lastSyncGlobalState ? lastSyncGlobalState.storage : null); + const local: IGlobalState | undefined = localArgv || localStorage ? { argv: localArgv || localGloablState.argv, storage: localStorage || localGloablState.storage } : undefined; + const remote: IGlobalState | undefined = remoteArgv || remoteStorage ? { argv: remoteArgv || remoteGlobalState.argv, storage: remoteStorage || remoteGlobalState.storage } : undefined; + + return { local, remote }; +} + +function doMerge(local: IStringDictionary, remote: IStringDictionary, base: IStringDictionary | null): { local?: IStringDictionary, remote?: IStringDictionary } { + const localToRemote = compare(local, remote); + if (localToRemote.added.size === 0 && localToRemote.removed.size === 0 && localToRemote.updated.size === 0) { + // No changes found between local and remote. + return {}; + } + + const baseToRemote = base ? compare(base, remote) : { added: Object.keys(remote).reduce((r, k) => { r.add(k); return r; }, new Set()), removed: new Set(), updated: new Set() }; + if (baseToRemote.added.size === 0 && baseToRemote.removed.size === 0 && baseToRemote.updated.size === 0) { + // No changes found between base and remote. + return { remote: local }; + } + + const baseToLocal = base ? compare(base, local) : { added: Object.keys(local).reduce((r, k) => { r.add(k); return r; }, new Set()), removed: new Set(), updated: new Set() }; + if (baseToLocal.added.size === 0 && baseToLocal.removed.size === 0 && baseToLocal.updated.size === 0) { + // No changes found between base and local. + return { local: remote }; + } + + const merged = objects.deepClone(local); + + // Added in remote + for (const key of values(baseToRemote.added)) { + merged[key] = remote[key]; + } + + // Updated in Remote + for (const key of values(baseToRemote.updated)) { + merged[key] = remote[key]; + } + + // Removed in remote & local + for (const key of values(baseToRemote.removed)) { + // Got removed in local + if (baseToLocal.removed.has(key)) { + delete merged[key]; + } + } + + return { local: merged, remote: merged }; +} + +function compare(from: IStringDictionary, to: IStringDictionary): { added: Set, removed: Set, updated: Set } { + const fromKeys = Object.keys(from); + const toKeys = Object.keys(to); + const added = toKeys.filter(key => fromKeys.indexOf(key) === -1).reduce((r, key) => { r.add(key); return r; }, new Set()); + const removed = fromKeys.filter(key => toKeys.indexOf(key) === -1).reduce((r, key) => { r.add(key); return r; }, new Set()); + const updated: Set = new Set(); + + for (const key of fromKeys) { + if (removed.has(key)) { + continue; + } + const value1 = from[key]; + const value2 = to[key]; + if (!objects.equals(value1, value2)) { + updated.add(key); + } + } + + return { added, removed, updated }; +} + diff --git a/src/vs/platform/userDataSync/common/globalStateSync.ts b/src/vs/platform/userDataSync/common/globalStateSync.ts new file mode 100644 index 00000000000..ff5ce0d5ae9 --- /dev/null +++ b/src/vs/platform/userDataSync/common/globalStateSync.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IUserData, UserDataSyncStoreError, UserDataSyncStoreErrorCode, ISynchroniser, SyncStatus, IUserDataSyncStoreService, IUserDataSyncLogService, IGlobalState } from 'vs/platform/userDataSync/common/userDataSync'; +import { VSBuffer } from 'vs/base/common/buffer'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { URI } from 'vs/base/common/uri'; +import { joinPath, dirname } from 'vs/base/common/resources'; +import { IFileService } from 'vs/platform/files/common/files'; +import { IStringDictionary } from 'vs/base/common/collections'; +import { edit } from 'vs/platform/userDataSync/common/content'; +import { merge } from 'vs/platform/userDataSync/common/globalStateMerge'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { parse } from 'vs/base/common/json'; + +const argvProperties: string[] = ['locale']; + +export class GlobalStateSynchroniser extends Disposable implements ISynchroniser { + + private static EXTERNAL_USER_DATA_GLOBAL_STATE_KEY: string = 'globalState'; + + private _status: SyncStatus = SyncStatus.Idle; + get status(): SyncStatus { return this._status; } + private _onDidChangStatus: Emitter = this._register(new Emitter()); + readonly onDidChangeStatus: Event = this._onDidChangStatus.event; + + private _onDidChangeLocal: Emitter = this._register(new Emitter()); + readonly onDidChangeLocal: Event = this._onDidChangeLocal.event; + + private readonly lastSyncGlobalStateResource: URI; + + constructor( + @IFileService private readonly fileService: IFileService, + @IUserDataSyncStoreService private readonly userDataSyncStoreService: IUserDataSyncStoreService, + @IUserDataSyncLogService private readonly logService: IUserDataSyncLogService, + @IEnvironmentService private readonly environmentService: IEnvironmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + this.lastSyncGlobalStateResource = joinPath(environmentService.userRoamingDataHome, '.lastSyncGlobalState'); + this._register(this.fileService.watch(dirname(this.environmentService.argvResource))); + this._register(Event.filter(this.fileService.onFileChanges, e => e.contains(this.environmentService.argvResource))(() => this._onDidChangeLocal.fire())); + } + + private setStatus(status: SyncStatus): void { + if (this._status !== status) { + this._status = status; + this._onDidChangStatus.fire(status); + } + } + + async sync(): Promise { + if (!this.configurationService.getValue('sync.enableUIState')) { + this.logService.trace('UI State: Skipping synchronizing UI state as it is disabled.'); + return false; + } + + if (this.status !== SyncStatus.Idle) { + this.logService.trace('UI State: Skipping synchronizing ui state as it is running already.'); + return false; + } + + this.logService.trace('UI State: Started synchronizing ui state...'); + this.setStatus(SyncStatus.Syncing); + + try { + await this.doSync(); + this.logService.trace('UI State: Finised synchronizing ui state.'); + this.setStatus(SyncStatus.Idle); + return true; + } catch (e) { + this.setStatus(SyncStatus.Idle); + if (e instanceof UserDataSyncStoreError && e.code === UserDataSyncStoreErrorCode.Rejected) { + // Rejected as there is a new remote version. Syncing again, + this.logService.info('UI State: Failed to synchronise ui state as there is a new remote version available. Synchronizing again...'); + return this.sync(); + } + throw e; + } + } + + stop(): void { } + + private async doSync(): Promise { + const lastSyncData = await this.getLastSyncUserData(); + const lastSyncGlobalState = lastSyncData && lastSyncData.content ? JSON.parse(lastSyncData.content) : null; + + let remoteData = await this.userDataSyncStoreService.read(GlobalStateSynchroniser.EXTERNAL_USER_DATA_GLOBAL_STATE_KEY, lastSyncData); + const remoteGlobalState: IGlobalState = remoteData.content ? JSON.parse(remoteData.content) : null; + + const localGloablState = await this.getLocalGlobalState(); + + const { local, remote } = merge(localGloablState, remoteGlobalState, lastSyncGlobalState); + + if (local) { + // update local + this.logService.info('UI State: Updating local ui state...'); + await this.writeLocalGlobalState(local); + } + + if (remote) { + // update remote + this.logService.info('UI State: Updating remote ui state...'); + remoteData = await this.writeToRemote(remote, remoteData.ref); + } + + if (remoteData.content + && (!lastSyncData || lastSyncData.ref !== remoteData.ref) + ) { + // update last sync + this.logService.info('UI State: Updating last synchronised ui state...'); + await this.updateLastSyncValue(remoteData); + } + } + + private async getLocalGlobalState(): Promise { + const argv: IStringDictionary = {}; + const storage: IStringDictionary = {}; + try { + const content = await this.fileService.readFile(this.environmentService.argvResource); + const argvValue: IStringDictionary = parse(content.value.toString()); + for (const argvProperty of argvProperties) { + if (argvValue[argvProperty] !== undefined) { + argv[argvProperty] = argvValue[argvProperty]; + } + } + } catch (error) { } + return { argv, storage }; + } + + private async writeLocalGlobalState(globalState: IGlobalState): Promise { + const content = await this.fileService.readFile(this.environmentService.argvResource); + let argvContent = content.value.toString(); + for (const argvProperty of Object.keys(globalState.argv)) { + argvContent = edit(argvContent, [argvProperty], globalState.argv[argvProperty], {}); + } + if (argvContent !== content.value.toString()) { + await this.fileService.writeFile(this.environmentService.argvResource, VSBuffer.fromString(argvContent)); + } + } + + private async getLastSyncUserData(): Promise { + try { + const content = await this.fileService.readFile(this.lastSyncGlobalStateResource); + return JSON.parse(content.value.toString()); + } catch (error) { + return null; + } + } + + private async updateLastSyncValue(remoteUserData: IUserData): Promise { + await this.fileService.writeFile(this.lastSyncGlobalStateResource, VSBuffer.fromString(JSON.stringify(remoteUserData))); + } + + private async writeToRemote(globalState: IGlobalState, ref: string | null): Promise { + const content = JSON.stringify(globalState); + ref = await this.userDataSyncStoreService.write(GlobalStateSynchroniser.EXTERNAL_USER_DATA_GLOBAL_STATE_KEY, content, ref); + return { content, ref }; + } + +} diff --git a/src/vs/platform/userDataSync/common/userDataSync.ts b/src/vs/platform/userDataSync/common/userDataSync.ts index 21f92cacb8e..e273fdde569 100644 --- a/src/vs/platform/userDataSync/common/userDataSync.ts +++ b/src/vs/platform/userDataSync/common/userDataSync.ts @@ -49,18 +49,24 @@ export function registerConfiguration(): IDisposable { default: true, scope: ConfigurationScope.APPLICATION, }, - 'sync.enableExtensions': { - type: 'boolean', - description: localize('sync.enableExtensions', "Enable synchronizing extensions."), - default: true, - scope: ConfigurationScope.APPLICATION, - }, 'sync.enableKeybindings': { type: 'boolean', description: localize('sync.enableKeybindings', "Enable synchronizing keybindings."), default: true, scope: ConfigurationScope.APPLICATION, }, + 'sync.enableUIState': { + type: 'boolean', + description: localize('sync.enableUIState', "Enable synchronizing UI state."), + default: true, + scope: ConfigurationScope.APPLICATION, + }, + 'sync.enableExtensions': { + type: 'boolean', + description: localize('sync.enableExtensions', "Enable synchronizing extensions."), + default: true, + scope: ConfigurationScope.APPLICATION, + }, 'sync.keybindingsPerPlatform': { type: 'boolean', description: localize('sync.keybindingsPerPlatform', "Synchronize keybindings per platform."), @@ -145,6 +151,11 @@ export interface ISyncExtension { enabled: boolean; } +export interface IGlobalState { + argv: IStringDictionary; + storage: IStringDictionary; +} + export const enum SyncSource { Settings = 1, Keybindings, diff --git a/src/vs/platform/userDataSync/common/userDataSyncService.ts b/src/vs/platform/userDataSync/common/userDataSyncService.ts index b906b66aafa..7619d616ddb 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncService.ts @@ -12,6 +12,7 @@ import { ExtensionsSynchroniser } from 'vs/platform/userDataSync/common/extensio import { IExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; import { IAuthTokenService, AuthTokenStatus } from 'vs/platform/auth/common/auth'; import { KeybindingsSynchroniser } from 'vs/platform/userDataSync/common/keybindingsSync'; +import { GlobalStateSynchroniser } from 'vs/platform/userDataSync/common/globalStateSync'; export class UserDataSyncService extends Disposable implements IUserDataSyncService { @@ -32,6 +33,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ private readonly settingsSynchroniser: SettingsSynchroniser; private readonly keybindingsSynchroniser: KeybindingsSynchroniser; private readonly extensionsSynchroniser: ExtensionsSynchroniser; + private readonly globalStateSynchroniser: GlobalStateSynchroniser; constructor( @IUserDataSyncStoreService private readonly userDataSyncStoreService: IUserDataSyncStoreService, @@ -41,8 +43,9 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ super(); this.settingsSynchroniser = this._register(this.instantiationService.createInstance(SettingsSynchroniser)); this.keybindingsSynchroniser = this._register(this.instantiationService.createInstance(KeybindingsSynchroniser)); + this.globalStateSynchroniser = this._register(this.instantiationService.createInstance(GlobalStateSynchroniser)); this.extensionsSynchroniser = this._register(this.instantiationService.createInstance(ExtensionsSynchroniser)); - this.synchronisers = [this.settingsSynchroniser, this.keybindingsSynchroniser, this.extensionsSynchroniser]; + this.synchronisers = [this.settingsSynchroniser, this.keybindingsSynchroniser, this.globalStateSynchroniser, this.extensionsSynchroniser]; this.updateStatus(); if (this.userDataSyncStoreService.userDataSyncStore) { diff --git a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts index 8fd8c739cf4..69eac6711ac 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -212,6 +212,9 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo }, { id: 'sync.enableKeybindings', label: localize('user keybindings', "User Keybindings") + }, { + id: 'sync.enableUIState', + label: localize('ui state', "UI State") }, { id: 'sync.enableExtensions', label: localize('extensions', "Extensions")