diff --git a/src/vs/platform/userDataSync/common/snippetsMerge.ts b/src/vs/platform/userDataSync/common/snippetsMerge.ts new file mode 100644 index 0000000000000..b16ab9ce883fa --- /dev/null +++ b/src/vs/platform/userDataSync/common/snippetsMerge.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { values } from 'vs/base/common/map'; +import { IStringDictionary } from 'vs/base/common/collections'; +import { deepClone } from 'vs/base/common/objects'; + +export interface IMergeResult { + added: IStringDictionary; + updated: IStringDictionary; + removed: string[]; + conflicts: string[]; + remote: IStringDictionary | null; +} + +export function merge(local: IStringDictionary, remote: IStringDictionary | null, base: IStringDictionary | null): IMergeResult { + const added: IStringDictionary = {}; + const updated: IStringDictionary = {}; + const removed: string[] = []; + + if (!remote) { + return { + added, + removed, + updated, + conflicts: [], + remote: local + }; + } + + 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 { + added, + removed, + updated, + conflicts: [], + remote: null + }; + } + + const baseToLocal = compare(base, local); + const baseToRemote = compare(base, remote); + const conflicts: Set = new Set(); + let remoteContent: IStringDictionary = deepClone(remote); + + // Removed snippets in Local + for (const key of values(baseToLocal.removed)) { + // Conflict - Got updated in remote. + if (baseToRemote.updated.has(key)) { + // Add to local + added[key] = remote[key]; + } + // Remove it in remote + else { + delete remoteContent[key]; + } + } + + // Removed snippets in Remote + for (const key of values(baseToRemote.removed)) { + if (conflicts.has(key)) { + continue; + } + // Conflict - Got updated in local + if (baseToLocal.updated.has(key)) { + conflicts.add(key); + } + // Also remove in Local + else { + removed.push(key); + } + } + + // Updated snippets in Local + for (const key of values(baseToLocal.updated)) { + if (conflicts.has(key)) { + continue; + } + // Got updated in remote + if (baseToRemote.updated.has(key)) { + // Has different value + if (localToRemote.updated.has(key)) { + conflicts.add(key); + } + } else { + remoteContent[key] = local[key]; + } + } + + // Updated snippets in Remote + for (const key of values(baseToRemote.updated)) { + if (conflicts.has(key)) { + continue; + } + // Got updated in local + if (baseToLocal.updated.has(key)) { + // Has different value + if (localToRemote.updated.has(key)) { + conflicts.add(key); + } + } else if (local[key] !== undefined) { + updated[key] = remote[key]; + } + } + + // Added snippets in Local + for (const key of values(baseToLocal.added)) { + if (conflicts.has(key)) { + continue; + } + // Got added in remote + if (baseToRemote.added.has(key)) { + // Has different value + if (localToRemote.updated.has(key)) { + conflicts.add(key); + } + } else { + remoteContent[key] = local[key]; + } + } + + // Added snippets in remote + for (const key of values(baseToRemote.added)) { + if (conflicts.has(key)) { + continue; + } + // Got added in local + if (baseToLocal.added.has(key)) { + // Has different value + if (localToRemote.updated.has(key)) { + conflicts.add(key); + } + } else { + added[key] = remote[key]; + } + } + + return { added, removed, updated, conflicts: values(conflicts), remote: areSame(remote, remoteContent) ? null : remoteContent }; +} + +function compare(from: IStringDictionary | null, to: IStringDictionary | null): { added: Set, removed: Set, updated: Set } { + const fromKeys = from ? Object.keys(from) : []; + const toKeys = to ? 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 fromSnippet = from![key]!; + const toSnippet = to![key]!; + if (fromSnippet !== toSnippet) { + updated.add(key); + } + } + + return { added, removed, updated }; +} + +function areSame(a: IStringDictionary, b: IStringDictionary): boolean { + const { added, removed, updated } = compare(a, b); + return added.size === 0 && removed.size === 0 && updated.size === 0; +} diff --git a/src/vs/platform/userDataSync/common/snippetsSync.ts b/src/vs/platform/userDataSync/common/snippetsSync.ts new file mode 100644 index 0000000000000..6a23ddade0bfa --- /dev/null +++ b/src/vs/platform/userDataSync/common/snippetsSync.ts @@ -0,0 +1,399 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SyncStatus, IUserDataSyncStoreService, IUserDataSyncLogService, IUserDataSynchroniser, SyncResource, IUserDataSyncEnablementService, IUserDataSyncBackupStoreService, Conflict, USER_DATA_SYNC_SCHEME, PREVIEW_DIR_NAME, UserDataSyncError, UserDataSyncErrorCode } from 'vs/platform/userDataSync/common/userDataSync'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IFileService, FileChangesEvent, IFileStat, IFileContent, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { AbstractSynchroniser, IRemoteUserData, ISyncData } from 'vs/platform/userDataSync/common/abstractSynchronizer'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IStringDictionary } from 'vs/base/common/collections'; +import { URI } from 'vs/base/common/uri'; +import { joinPath, extname, relativePath, isEqualOrParent, isEqual, basename } from 'vs/base/common/resources'; +import { VSBuffer } from 'vs/base/common/buffer'; +import { merge } from 'vs/platform/userDataSync/common/snippetsMerge'; +import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; +import { CancellationToken } from 'vs/base/common/cancellation'; + +interface ISyncPreviewResult { + readonly local: IStringDictionary; + readonly remoteUserData: IRemoteUserData; + readonly lastSyncUserData: IRemoteUserData | null; + readonly added: IStringDictionary; + readonly updated: IStringDictionary; + readonly removed: string[]; + readonly conflicts: Conflict[]; + readonly remote: IStringDictionary | null; +} + +export class SnippetsSynchroniser extends AbstractSynchroniser implements IUserDataSynchroniser { + + protected readonly version: number = 1; + private readonly snippetsFolder: URI; + private readonly snippetsPreviewFolder: URI; + private syncPreviewResultPromise: CancelablePromise | null = null; + + constructor( + @IEnvironmentService environmentService: IEnvironmentService, + @IFileService fileService: IFileService, + @IUserDataSyncStoreService userDataSyncStoreService: IUserDataSyncStoreService, + @IUserDataSyncBackupStoreService userDataSyncBackupStoreService: IUserDataSyncBackupStoreService, + @IUserDataSyncLogService logService: IUserDataSyncLogService, + @IConfigurationService configurationService: IConfigurationService, + @IUserDataSyncEnablementService userDataSyncEnablementService: IUserDataSyncEnablementService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(SyncResource.Snippets, fileService, environmentService, userDataSyncStoreService, userDataSyncBackupStoreService, userDataSyncEnablementService, telemetryService, logService, configurationService); + this.snippetsFolder = environmentService.snippetsHome; + this.snippetsPreviewFolder = joinPath(this.syncFolder, PREVIEW_DIR_NAME); + this._register(this.fileService.watch(environmentService.userRoamingDataHome)); + this._register(this.fileService.watch(this.snippetsFolder)); + this._register(this.fileService.onDidFilesChange(e => this.onFileChanges(e))); + } + + private onFileChanges(e: FileChangesEvent): void { + if (!e.changes.some(change => isEqualOrParent(change.resource, this.snippetsFolder))) { + return; + } + if (!this.isEnabled()) { + return; + } + // Sync again if local file has changed and current status is in conflicts + if (this.status === SyncStatus.HasConflicts) { + this.syncPreviewResultPromise!.then(result => { + this.cancel(); + this.doSync(result.remoteUserData, result.lastSyncUserData).then(status => this.setStatus(status)); + }); + } + // Otherwise fire change event + else { + this._onDidChangeLocal.fire(); + } + } + + async pull(): Promise { + if (!this.isEnabled()) { + this.logService.info(`${this.syncResourceLogLabel}: Skipped pulling snippets as it is disabled.`); + return; + } + + this.stop(); + + try { + this.logService.info(`${this.syncResourceLogLabel}: Started pulling snippets...`); + this.setStatus(SyncStatus.Syncing); + + const lastSyncUserData = await this.getLastSyncUserData(); + const remoteUserData = await this.getRemoteUserData(lastSyncUserData); + + if (remoteUserData.syncData !== null) { + const local = await this.getSnippetsFileContents(); + const localSnippets = this.toSnippetsContents(local); + const remoteSnippets = this.parseSnippets(remoteUserData.syncData); + const { added, updated, remote, removed } = merge(localSnippets, remoteSnippets, localSnippets); + this.syncPreviewResultPromise = createCancelablePromise(() => Promise.resolve({ + added, removed, updated, remote, remoteUserData, local, lastSyncUserData, conflicts: [] + })); + await this.apply(); + } + + // No remote exists to pull + else { + this.logService.info(`${this.syncResourceLogLabel}: Remote snippets does not exist.`); + } + + this.logService.info(`${this.syncResourceLogLabel}: Finished pulling snippets.`); + } finally { + this.setStatus(SyncStatus.Idle); + } + } + + async push(): Promise { + if (!this.isEnabled()) { + this.logService.info(`${this.syncResourceLogLabel}: Skipped pushing snippets as it is disabled.`); + return; + } + + this.stop(); + + try { + this.logService.info(`${this.syncResourceLogLabel}: Started pushing snippets...`); + this.setStatus(SyncStatus.Syncing); + + const local = await this.getSnippetsFileContents(); + const localSnippets = this.toSnippetsContents(local); + const { added, removed, updated, remote } = merge(localSnippets, null, null); + const lastSyncUserData = await this.getLastSyncUserData(); + const remoteUserData = await this.getRemoteUserData(lastSyncUserData); + this.syncPreviewResultPromise = createCancelablePromise(() => Promise.resolve({ + added, removed, updated, remote, remoteUserData, local, lastSyncUserData, conflicts: [] + })); + + await this.apply(true); + + this.logService.info(`${this.syncResourceLogLabel}: Finished pushing snippets.`); + } finally { + this.setStatus(SyncStatus.Idle); + } + + } + + async stop(): Promise { + await this.clearConflicts(); + this.cancel(); + this.logService.info(`${this.syncResourceLogLabel}: Stopped synchronizing ${this.syncResourceLogLabel}.`); + this.setStatus(SyncStatus.Idle); + } + + async getConflictContent(conflictResource: URI): Promise { + if (isEqualOrParent(conflictResource.with({ scheme: this.syncFolder.scheme }), this.snippetsPreviewFolder) && this.syncPreviewResultPromise) { + const result = await this.syncPreviewResultPromise; + const key = relativePath(this.snippetsPreviewFolder, conflictResource.with({ scheme: this.snippetsPreviewFolder.scheme }))!; + if (conflictResource.scheme === this.snippetsPreviewFolder.scheme) { + return result.local[key] ? result.local[key].value.toString() : null; + } else if (result.remoteUserData && result.remoteUserData.syncData) { + const snippets = this.parseSnippets(result.remoteUserData.syncData); + return snippets[key] || null; + } + } + return null; + } + + async getRemoteContent(ref?: string, fragment?: string): Promise { + const content = await super.getRemoteContent(ref); + if (content !== null && fragment) { + return this.getFragment(content, fragment); + } + return content; + } + + async getLocalBackupContent(ref?: string, fragment?: string): Promise { + let content = await super.getLocalBackupContent(ref); + if (content !== null && fragment) { + return this.getFragment(content, fragment); + } + return content; + } + + private getFragment(content: string, fragment: string): string | null { + const syncData = this.parseSyncData(content); + return syncData ? this.getFragmentFromSyncData(syncData, fragment) : null; + } + + private getFragmentFromSyncData(syncData: ISyncData, fragment: string): string | null { + switch (fragment) { + case 'snippets': + return syncData.content; + default: + const remoteSnippets = this.parseSnippets(syncData); + return remoteSnippets[fragment] || null; + } + } + + async acceptConflict(conflictResource: URI, content: string): Promise { + const conflict = this.conflicts.filter(({ local, remote }) => isEqual(local, conflictResource) || isEqual(remote, conflictResource))[0]; + if (this.status === SyncStatus.HasConflicts && conflict) { + const key = relativePath(this.snippetsPreviewFolder, conflict.local)!; + const result = await this.syncPreviewResultPromise!; + if (content) { + if (result.local[key]) { + result.updated[key] = content; + } else { + result.added[key] = content; + } + } else { + result.removed.push(key); + } + await this.fileService.del(conflict.local); + this.setConflicts(this.conflicts.filter(c => c !== conflict)); + if (!this.conflicts.length) { + await this.apply(); + this.setStatus(SyncStatus.Idle); + } + } + } + + async hasLocalData(): Promise { + try { + const localSnippets = await this.getSnippetsFileContents(); + if (Object.keys(localSnippets).length) { + return true; + } + } catch (error) { + /* ignore error */ + } + return false; + } + + protected async performSync(remoteUserData: IRemoteUserData, lastSyncUserData: IRemoteUserData | null): Promise { + try { + const previewResult = await this.getPreview(remoteUserData, lastSyncUserData); + if (previewResult.conflicts.length) { + return SyncStatus.HasConflicts; + } + await this.apply(); + return SyncStatus.Idle; + } catch (e) { + this.syncPreviewResultPromise = null; + if (e instanceof UserDataSyncError) { + switch (e.code) { + case UserDataSyncErrorCode.LocalPreconditionFailed: + // Rejected as there is a new local version. Syncing again. + this.logService.info(`${this.syncResourceLogLabel}: Failed to synchronize snippets as there is a new local version available. Synchronizing again...`); + return this.performSync(remoteUserData, lastSyncUserData); + } + } + throw e; + } + } + + private getPreview(remoteUserData: IRemoteUserData, lastSyncUserData: IRemoteUserData | null): Promise { + if (!this.syncPreviewResultPromise) { + this.syncPreviewResultPromise = createCancelablePromise(token => this.generatePreview(remoteUserData, lastSyncUserData, token)); + } + return this.syncPreviewResultPromise; + } + + protected cancel(): void { + if (this.syncPreviewResultPromise) { + this.syncPreviewResultPromise.cancel(); + this.syncPreviewResultPromise = null; + } + } + + private async clearConflicts(): Promise { + if (this.conflicts.length) { + await Promise.all(this.conflicts.map(({ local }) => this.fileService.del(local))); + this.setConflicts([]); + } + } + + private async generatePreview(remoteUserData: IRemoteUserData, lastSyncUserData: IRemoteUserData | null, token: CancellationToken): Promise { + await this.clearConflicts(); + + const remoteSnippets: IStringDictionary | null = remoteUserData.syncData ? this.parseSnippets(remoteUserData.syncData) : null; + const lastSyncSnippets: IStringDictionary | null = lastSyncUserData ? this.parseSnippets(lastSyncUserData.syncData!) : null; + + const local = await this.getSnippetsFileContents(); + const localSnippets = this.toSnippetsContents(local); + + if (remoteSnippets) { + this.logService.trace(`${this.syncResourceLogLabel}: Merging remote snippets with local snippets...`); + } else { + this.logService.trace(`${this.syncResourceLogLabel}: Remote snippets does not exist. Synchronizing snippets for the first time.`); + } + + const mergeResult = merge(localSnippets, remoteSnippets, lastSyncSnippets); + const conflicts: Conflict[] = []; + for (const key of mergeResult.conflicts) { + const localPreview = joinPath(this.snippetsPreviewFolder, key); + conflicts.push({ local: localPreview, remote: localPreview.with({ scheme: USER_DATA_SYNC_SCHEME }) }); + const content = local[key]; + if (!token.isCancellationRequested) { + await this.fileService.writeFile(localPreview, content ? content.value : VSBuffer.fromString('')); + } + } + + this.setConflicts(!token.isCancellationRequested ? conflicts : []); + + return { remoteUserData, local, lastSyncUserData, added: mergeResult.added, removed: mergeResult.removed, updated: mergeResult.updated, conflicts, remote: mergeResult.remote }; + } + + private async apply(forcePush?: boolean): Promise { + if (!this.syncPreviewResultPromise) { + return; + } + + let { added, removed, updated, local, remote, remoteUserData, lastSyncUserData } = await this.syncPreviewResultPromise; + + const hasChanges = Object.keys(added).length || removed.length || Object.keys(updated).length || remote; + + if (!hasChanges) { + this.logService.info(`${this.syncResourceLogLabel}: No changes found during synchronizing snippets.`); + } + + if (Object.keys(added).length || removed.length || Object.keys(updated).length) { + // back up all snippets + await this.backupLocal(JSON.stringify(this.toSnippetsContents(local))); + await this.updateLocalSnippets(added, removed, updated, local); + } + + if (remote) { + // update remote + this.logService.trace(`${this.syncResourceLogLabel}: Updating remote snippets...`); + const content = JSON.stringify(remote); + remoteUserData = await this.updateRemoteUserData(content, forcePush ? null : remoteUserData.ref); + this.logService.info(`${this.syncResourceLogLabel}: Updated remote snippets`); + } + + if (lastSyncUserData?.ref !== remoteUserData.ref) { + // update last sync + this.logService.trace(`${this.syncResourceLogLabel}: Updating last synchronized snippets...`); + await this.updateLastSyncUserData(remoteUserData); + this.logService.info(`${this.syncResourceLogLabel}: Updated last synchronized snippets`); + } + + this.syncPreviewResultPromise = null; + } + + private async updateLocalSnippets(added: IStringDictionary, removed: string[], updated: IStringDictionary, local: IStringDictionary): Promise { + for (const key of removed) { + const resource = joinPath(this.snippetsFolder, key); + this.logService.trace(`${this.syncResourceLogLabel}: Deleting snippet...`, basename(resource)); + await this.fileService.del(resource); + this.logService.info(`${this.syncResourceLogLabel}: Deleted snippet`, basename(resource)); + } + + for (const key of Object.keys(added)) { + const resource = joinPath(this.snippetsFolder, key); + this.logService.trace(`${this.syncResourceLogLabel}: Creating snippet...`, basename(resource)); + await this.fileService.createFile(resource, VSBuffer.fromString(added[key]), { overwrite: false }); + this.logService.info(`${this.syncResourceLogLabel}: Created snippet`, basename(resource)); + } + + for (const key of Object.keys(updated)) { + const resource = joinPath(this.snippetsFolder, key); + this.logService.trace(`${this.syncResourceLogLabel}: Updating snippet...`, basename(resource)); + await this.fileService.writeFile(resource, VSBuffer.fromString(updated[key]), local[key]); + this.logService.info(`${this.syncResourceLogLabel}: Updated snippet`, basename(resource)); + } + } + + private parseSnippets(syncData: ISyncData): IStringDictionary { + return JSON.parse(syncData.content); + } + + private toSnippetsContents(snippetsFileContents: IStringDictionary): IStringDictionary { + const snippets: IStringDictionary = {}; + for (const key of Object.keys(snippetsFileContents)) { + snippets[key] = snippetsFileContents[key].value.toString(); + } + return snippets; + } + + private async getSnippetsFileContents(): Promise> { + const snippets: IStringDictionary = {}; + let stat: IFileStat; + try { + stat = await this.fileService.resolve(this.snippetsFolder); + } catch (e) { + // No snippets + if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + return snippets; + } else { + throw e; + } + } + for (const entry of stat.children || []) { + const resource = entry.resource; + if (extname(resource) === '.json') { + const key = relativePath(this.snippetsFolder, resource)!; + const content = await this.fileService.readFile(resource); + snippets[key] = content; + } + } + return snippets; + } +} diff --git a/src/vs/platform/userDataSync/common/userDataSync.ts b/src/vs/platform/userDataSync/common/userDataSync.ts index 4785ab9d21632..553d241c96c19 100644 --- a/src/vs/platform/userDataSync/common/userDataSync.ts +++ b/src/vs/platform/userDataSync/common/userDataSync.ts @@ -138,10 +138,11 @@ export function getUserDataSyncStore(productService: IProductService, configurat export const enum SyncResource { Settings = 'settings', Keybindings = 'keybindings', + Snippets = 'snippets', Extensions = 'extensions', GlobalState = 'globalState' } -export const ALL_SYNC_RESOURCES: SyncResource[] = [SyncResource.Settings, SyncResource.Keybindings, SyncResource.Extensions, SyncResource.GlobalState]; +export const ALL_SYNC_RESOURCES: SyncResource[] = [SyncResource.Settings, SyncResource.Keybindings, SyncResource.Snippets, SyncResource.Extensions, SyncResource.GlobalState]; export interface IUserDataManifest { latest?: Record @@ -373,10 +374,3 @@ export function getSyncResourceFromLocalPreview(localPreview: URI, environmentSe localPreview = localPreview.with({ scheme: environmentService.userDataSyncHome.scheme }); return ALL_SYNC_RESOURCES.filter(syncResource => isEqualOrParent(localPreview, joinPath(environmentService.userDataSyncHome, syncResource, PREVIEW_DIR_NAME)))[0]; } -export function getSyncResourceFromRemotePreview(remotePreview: URI, environmentService: IEnvironmentService): SyncResource | undefined { - if (remotePreview.scheme !== USER_DATA_SYNC_SCHEME) { - return undefined; - } - remotePreview = remotePreview.with({ scheme: environmentService.userDataSyncHome.scheme }); - return ALL_SYNC_RESOURCES.filter(syncResource => isEqualOrParent(remotePreview, joinPath(environmentService.userDataSyncHome, syncResource, PREVIEW_DIR_NAME)))[0]; -} diff --git a/src/vs/platform/userDataSync/common/userDataSyncService.ts b/src/vs/platform/userDataSync/common/userDataSyncService.ts index 4049ab3c30d10..92a7904641117 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncService.ts @@ -18,6 +18,7 @@ import { IStorageService, StorageScope } from 'vs/platform/storage/common/storag import { URI } from 'vs/base/common/uri'; import { SettingsSynchroniser } from 'vs/platform/userDataSync/common/settingsSync'; import { isEqual } from 'vs/base/common/resources'; +import { SnippetsSynchroniser } from 'vs/platform/userDataSync/common/snippetsSync'; type SyncErrorClassification = { source: { classification: 'SystemMetaData', purpose: 'FeatureInsight', isMeasurement: true }; @@ -55,6 +56,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ private readonly settingsSynchroniser: SettingsSynchroniser; private readonly keybindingsSynchroniser: KeybindingsSynchroniser; + private readonly snippetsSynchroniser: SnippetsSynchroniser; private readonly extensionsSynchroniser: ExtensionsSynchroniser; private readonly globalStateSynchroniser: GlobalStateSynchroniser; @@ -68,9 +70,10 @@ 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.snippetsSynchroniser = this._register(this.instantiationService.createInstance(SnippetsSynchroniser)); this.globalStateSynchroniser = this._register(this.instantiationService.createInstance(GlobalStateSynchroniser)); this.extensionsSynchroniser = this._register(this.instantiationService.createInstance(ExtensionsSynchroniser)); - this.synchronisers = [this.settingsSynchroniser, this.keybindingsSynchroniser, this.globalStateSynchroniser, this.extensionsSynchroniser]; + this.synchronisers = [this.settingsSynchroniser, this.keybindingsSynchroniser, this.snippetsSynchroniser, 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 9b778c41ba586..3f972a36c9421 100644 --- a/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts +++ b/src/vs/workbench/contrib/userDataSync/browser/userDataSync.ts @@ -9,7 +9,7 @@ import { canceled, isPromiseCanceledError } from 'vs/base/common/errors'; import { Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, dispose, MutableDisposable, toDisposable, IDisposable } from 'vs/base/common/lifecycle'; import { isWeb } from 'vs/base/common/platform'; -import { isEqual } from 'vs/base/common/resources'; +import { isEqual, basename } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import type { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; @@ -32,7 +32,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { CONTEXT_SYNC_STATE, getUserDataSyncStore, ISyncConfiguration, IUserDataAutoSyncService, IUserDataSyncService, IUserDataSyncStore, registerConfiguration, SyncResource, SyncStatus, UserDataSyncError, UserDataSyncErrorCode, USER_DATA_SYNC_SCHEME, IUserDataSyncEnablementService, CONTEXT_SYNC_ENABLEMENT, - SyncResourceConflicts, Conflict, getSyncResourceFromLocalPreview, getSyncResourceFromRemotePreview + SyncResourceConflicts, Conflict, getSyncResourceFromLocalPreview } from 'vs/platform/userDataSync/common/userDataSync'; import { FloatingClickWidget } from 'vs/workbench/browser/parts/editor/editorWidgets'; import { GLOBAL_ACTIVITY_ID } from 'vs/workbench/common/activity'; @@ -69,6 +69,7 @@ function getSyncAreaLabel(source: SyncResource): string { switch (source) { case SyncResource.Settings: return localize('settings', "Settings"); case SyncResource.Keybindings: return localize('keybindings', "Keyboard Shortcuts"); + case SyncResource.Snippets: return localize('snippets', "User Snippets"); case SyncResource.Extensions: return localize('extensions', "Extensions"); case SyncResource.GlobalState: return localize('ui state label', "UI State"); } @@ -100,6 +101,7 @@ const signInCommand = { id: 'workbench.userData.actions.signin', title: localize const stopSyncCommand = { id: 'workbench.userData.actions.stopSync', title(authenticationProviderId: string, account: AuthenticationSession | undefined, authenticationService: IAuthenticationService) { return getIdentityTitle(localize('stop sync', "Sync: Turn off Sync"), authenticationProviderId, account, authenticationService); } }; const resolveSettingsConflictsCommand = { id: 'workbench.userData.actions.resolveSettingsConflicts', title: localize('showConflicts', "Sync: Show Settings Conflicts") }; const resolveKeybindingsConflictsCommand = { id: 'workbench.userData.actions.resolveKeybindingsConflicts', title: localize('showKeybindingsConflicts', "Sync: Show Keybindings Conflicts") }; +const resolveSnippetsConflictsCommand = { id: 'workbench.userData.actions.resolveSnippetsConflicts', title: localize('showSnippetsConflicts', "Sync: Show User Snippets Conflicts") }; const configureSyncCommand = { id: 'workbench.userData.actions.configureSync', title: localize('configure sync', "Sync: Configure") }; const showSyncActivityCommand = { id: 'workbench.userData.actions.showSyncActivity', title(userDataSyncService: IUserDataSyncService): string { @@ -291,6 +293,9 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo if (conflicts.length) { const conflictsSources: SyncResource[] = conflicts.map(conflict => conflict.syncResource); this.conflictsSources.set(conflictsSources.join(',')); + if (conflictsSources.indexOf(SyncResource.Snippets) !== -1) { + this.registerShowSnippetsConflictsAction(); + } // Clear and dispose conflicts those were cleared this.conflictsDisposables.forEach((disposable, conflictsSource) => { @@ -301,8 +306,19 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo }); for (const { syncResource, conflicts } of this.userDataSyncService.conflicts) { - const conflictsEditorInput = this.getConflictsEditorInput(syncResource); - if (!conflictsEditorInput && !this.conflictsDisposables.has(syncResource)) { + const conflictsEditorInputs = this.getConflictsEditorInputs(syncResource); + + // close stale conflicts editor previews + if (conflictsEditorInputs.length) { + conflictsEditorInputs.forEach(input => { + if (!conflicts.some(({ local }) => isEqual(local, input.master.resource))) { + input.dispose(); + } + }); + } + + // Show conflicts notification if not shown before + else if (!this.conflictsDisposables.has(syncResource)) { const conflictsArea = getSyncAreaLabel(syncResource); const handle = this.notificationService.prompt(Severity.Warning, localize('conflicts detected', "Unable to sync due to conflicts in {0}. Please resolve them to continue.", conflictsArea.toLowerCase()), [ @@ -338,9 +354,9 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo handle.close(); // close opened conflicts editor previews - const conflictsEditorInput = this.getConflictsEditorInput(syncResource); - if (conflictsEditorInput) { - conflictsEditorInput.dispose(); + const conflictsEditorInputs = this.getConflictsEditorInputs(syncResource); + if (conflictsEditorInputs.length) { + conflictsEditorInputs.forEach(input => input.dispose()); } this.conflictsDisposables.delete(syncResource); @@ -496,7 +512,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo if (this.userDataSyncService.status !== SyncStatus.Uninitialized && this.userDataSyncEnablementService.isEnabled() && this.authenticationState.get() === AuthStatus.SignedOut) { badge = new NumberBadge(1, () => localize('sign in to sync', "Sign in to Sync")); } else if (this.userDataSyncService.conflicts.length) { - badge = new NumberBadge(this.userDataSyncService.conflicts.length, () => localize('has conflicts', "Sync: Conflicts Detected")); + badge = new NumberBadge(this.userDataSyncService.conflicts.reduce((result, syncResourceConflict) => { return result + syncResourceConflict.conflicts.length; }, 0), () => localize('has conflicts', "Sync: Conflicts Detected")); } if (badge) { @@ -605,6 +621,9 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo }, { id: SyncResource.Keybindings, label: getSyncAreaLabel(SyncResource.Keybindings) + }, { + id: SyncResource.Snippets, + label: getSyncAreaLabel(SyncResource.Snippets) }, { id: SyncResource.Extensions, label: getSyncAreaLabel(SyncResource.Extensions) @@ -712,6 +731,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo switch (source) { case SyncResource.Settings: return this.userDataSyncEnablementService.setResourceEnablement(SyncResource.Settings, false); case SyncResource.Keybindings: return this.userDataSyncEnablementService.setResourceEnablement(SyncResource.Keybindings, false); + case SyncResource.Snippets: return this.userDataSyncEnablementService.setResourceEnablement(SyncResource.Snippets, false); case SyncResource.Extensions: return this.userDataSyncEnablementService.setResourceEnablement(SyncResource.Extensions, false); case SyncResource.GlobalState: return this.userDataSyncEnablementService.setResourceEnablement(SyncResource.GlobalState, false); } @@ -727,8 +747,11 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo } } - private getConflictsEditorInput(syncResource: SyncResource): IEditorInput | undefined { - return this.editorService.editors.filter(input => input instanceof DiffEditorInput && getSyncResourceFromLocalPreview(input.master.resource!, this.workbenchEnvironmentService) === syncResource)[0]; + private getConflictsEditorInputs(syncResource: SyncResource): DiffEditorInput[] { + return this.editorService.editors.filter(input => { + const resource = input instanceof DiffEditorInput ? input.master.resource : input.resource; + return getSyncResourceFromLocalPreview(resource!, this.workbenchEnvironmentService) === syncResource; + }) as DiffEditorInput[]; } private getAllConflictsEditorInputs(): IEditorInput[] { @@ -752,6 +775,8 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo label = localize('settings conflicts preview', "Settings Conflicts (Remote ↔ Local)"); } else if (syncResource === SyncResource.Keybindings) { label = localize('keybindings conflicts preview', "Keybindings Conflicts (Remote ↔ Local)"); + } else if (syncResource === SyncResource.Snippets) { + label = localize('snippets conflicts preview', "User Snippet Conflicts (Remote ↔ Local) - {0}", basename(conflict.local)); } await this.editorService.openEditor({ leftResource: conflict.remote, @@ -775,6 +800,7 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo this.registerSignInAction(); this.registerShowSettingsConflictsAction(); this.registerShowKeybindingsConflictsAction(); + this.registerShowSnippetsConflictsAction(); this.registerSyncStatusAction(); this.registerTurnOffSyncAction(); @@ -894,7 +920,36 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo command: resolveKeybindingsConflictsCommand, when: resolveKeybindingsConflictsWhenContext, }); + } + private _snippetsConflictsActionsDisposable: DisposableStore = new DisposableStore(); + private registerShowSnippetsConflictsAction(): void { + this._snippetsConflictsActionsDisposable.clear(); + const resolveSnippetsConflictsWhenContext = ContextKeyExpr.regex(CONTEXT_CONFLICTS_SOURCES.keys()[0], /.*snippets.*/i); + const conflicts: Conflict[] | undefined = this.userDataSyncService.conflicts.filter(({ syncResource }) => syncResource === SyncResource.Snippets)[0]?.conflicts; + this._snippetsConflictsActionsDisposable.add(CommandsRegistry.registerCommand(resolveSnippetsConflictsCommand.id, () => this.handleSyncResourceConflicts(SyncResource.Snippets))); + this._snippetsConflictsActionsDisposable.add(MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { + group: '5_sync', + command: { + id: resolveSnippetsConflictsCommand.id, + title: localize('resolveSnippetsConflicts_global', "Sync: Show User Snippets Conflicts ({0})", conflicts?.length || 1), + }, + when: resolveSnippetsConflictsWhenContext, + order: 2 + })); + this._snippetsConflictsActionsDisposable.add(MenuRegistry.appendMenuItem(MenuId.MenubarPreferencesMenu, { + group: '5_sync', + command: { + id: resolveSnippetsConflictsCommand.id, + title: localize('resolveSnippetsConflicts_global', "Sync: Show User Snippets Conflicts ({0})", conflicts?.length || 1), + }, + when: resolveSnippetsConflictsWhenContext, + order: 2 + })); + this._snippetsConflictsActionsDisposable.add(MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: resolveSnippetsConflictsCommand, + when: resolveSnippetsConflictsWhenContext, + })); } private registerSyncStatusAction(): void { @@ -938,6 +993,9 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo case SyncResource.Keybindings: items.push({ id: resolveKeybindingsConflictsCommand.id, label: resolveKeybindingsConflictsCommand.title }); break; + case SyncResource.Snippets: + items.push({ id: resolveSnippetsConflictsCommand.id, label: resolveSnippetsConflictsCommand.title }); + break; } } items.push({ type: 'separator' }); @@ -1074,7 +1132,6 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio constructor( private editor: ICodeEditor, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IUserDataSyncService private readonly userDataSyncService: IUserDataSyncService, @INotificationService private readonly notificationService: INotificationService, @IDialogService private readonly dialogService: IDialogService, @@ -1088,7 +1145,8 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio } private registerListeners(): void { - this._register(this.editor.onDidChangeModel(e => this.update())); + this._register(this.editor.onDidChangeModel(() => this.update())); + this._register(this.userDataSyncService.onDidChangeConflicts(() => this.update())); this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('diffEditor.renderSideBySide'))(() => this.update())); } @@ -1107,11 +1165,16 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio return false; // we need a model } - if (getSyncResourceFromLocalPreview(model.uri, this.environmentService) !== undefined) { + const syncResourceConflicts = this.getSyncResourceConflicts(model.uri); + if (!syncResourceConflicts) { + return false; + } + + if (syncResourceConflicts.conflicts.some(({ local }) => isEqual(local, model.uri))) { return true; } - if (getSyncResourceFromRemotePreview(model.uri, this.environmentService) !== undefined) { + if (syncResourceConflicts.conflicts.some(({ remote }) => isEqual(remote, model.uri))) { return this.configurationService.getValue('diffEditor.renderSideBySide'); } @@ -1121,16 +1184,17 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio private createAcceptChangesWidgetRenderer(): void { if (!this.acceptChangesButton) { - const isRemote = getSyncResourceFromRemotePreview(this.editor.getModel()!.uri, this.environmentService) !== undefined; + const resource = this.editor.getModel()!.uri; + const syncResourceConflicts = this.getSyncResourceConflicts(resource)!; + const isRemote = syncResourceConflicts.conflicts.some(({ remote }) => isEqual(remote, resource)); const acceptRemoteLabel = localize('accept remote', "Accept Remote"); const acceptLocalLabel = localize('accept local', "Accept Local"); this.acceptChangesButton = this.instantiationService.createInstance(FloatingClickWidget, this.editor, isRemote ? acceptRemoteLabel : acceptLocalLabel, null); this._register(this.acceptChangesButton.onClick(async () => { const model = this.editor.getModel(); if (model) { - const conflictsSource = (getSyncResourceFromLocalPreview(model.uri, this.environmentService) || getSyncResourceFromRemotePreview(model.uri, this.environmentService))!; - this.telemetryService.publicLog2<{ source: string, action: string }, SyncConflictsClassification>('sync/handleConflicts', { source: conflictsSource, action: isRemote ? 'acceptRemote' : 'acceptLocal' }); - const syncAreaLabel = getSyncAreaLabel(conflictsSource); + this.telemetryService.publicLog2<{ source: string, action: string }, SyncConflictsClassification>('sync/handleConflicts', { source: syncResourceConflicts.syncResource, action: isRemote ? 'acceptRemote' : 'acceptLocal' }); + const syncAreaLabel = getSyncAreaLabel(syncResourceConflicts.syncResource); const result = await this.dialogService.confirm({ type: 'info', title: isRemote @@ -1146,7 +1210,7 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio await this.userDataSyncService.acceptConflict(model.uri, model.getValue()); } catch (e) { if (e instanceof UserDataSyncError && e.code === UserDataSyncErrorCode.LocalPreconditionFailed) { - const syncResourceCoflicts = this.userDataSyncService.conflicts.filter(({ syncResource }) => syncResource === conflictsSource)[0]; + const syncResourceCoflicts = this.userDataSyncService.conflicts.filter(({ syncResource }) => syncResource === syncResourceConflicts.syncResource)[0]; if (syncResourceCoflicts && syncResourceCoflicts.conflicts.some(conflict => isEqual(conflict.local, model.uri) || isEqual(conflict.remote, model.uri))) { this.notificationService.warn(localize('update conflicts', "Could not resolve conflicts as there is new local version available. Please try again.")); } @@ -1162,6 +1226,10 @@ class AcceptChangesContribution extends Disposable implements IEditorContributio } } + private getSyncResourceConflicts(resource: URI): SyncResourceConflicts | undefined { + return this.userDataSyncService.conflicts.filter(({ conflicts }) => conflicts.some(({ local, remote }) => isEqual(local, resource) || isEqual(remote, resource)))[0]; + } + private disposeAcceptChangesWidgetRenderer(): void { dispose(this.acceptChangesButton); this.acceptChangesButton = undefined;