diff --git a/src/vs/workbench/contrib/userData/common/userData.contribution.ts b/src/vs/workbench/contrib/userData/browser/userData.contribution.ts similarity index 95% rename from src/vs/workbench/contrib/userData/common/userData.contribution.ts rename to src/vs/workbench/contrib/userData/browser/userData.contribution.ts index 6ff9e49344c1f..b192380585254 100644 --- a/src/vs/workbench/contrib/userData/common/userData.contribution.ts +++ b/src/vs/workbench/contrib/userData/browser/userData.contribution.ts @@ -18,6 +18,8 @@ import { FalseContext } from 'vs/platform/contextkey/common/contextkeys'; import { IActivityService, IBadge, NumberBadge, ProgressBadge } from 'vs/workbench/services/activity/common/activity'; import { GLOBAL_ACTIVITY_ID } from 'vs/workbench/common/activity'; import { timeout } from 'vs/base/common/async'; +import { registerEditorContribution } from 'vs/editor/browser/editorExtensions'; +import { AcceptChangesController } from 'vs/workbench/contrib/userData/browser/userDataPreviewEditorContribution'; const CONTEXT_SYNC_STATE = new RawContextKey('syncStatus', SyncStatus.Uninitialized); @@ -129,7 +131,7 @@ class SyncContribution extends Disposable implements IWorkbenchContribution { when: ContextKeyExpr.and(CONTEXT_SYNC_STATE.notEqualsTo(SyncStatus.Uninitialized), ContextKeyExpr.has('config.userConfiguration.autoSync')), }); - CommandsRegistry.registerCommand('sync.resolveConflicts', serviceAccessor => serviceAccessor.get(IUserDataSyncService).resolveConflicts()); + CommandsRegistry.registerCommand('sync.resolveConflicts', serviceAccessor => serviceAccessor.get(IUserDataSyncService).handleConflicts()); MenuRegistry.appendMenuItem(MenuId.GlobalActivity, { group: '5_sync', command: { @@ -174,3 +176,5 @@ class SyncContribution extends Disposable implements IWorkbenchContribution { const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); workbenchRegistry.registerWorkbenchContribution(SyncContribution, LifecyclePhase.Starting); workbenchRegistry.registerWorkbenchContribution(AutoSyncUserData, LifecyclePhase.Eventually); + +registerEditorContribution(AcceptChangesController); diff --git a/src/vs/workbench/contrib/userData/browser/userDataPreviewEditorContribution.ts b/src/vs/workbench/contrib/userData/browser/userDataPreviewEditorContribution.ts new file mode 100644 index 0000000000000..5ef756c569181 --- /dev/null +++ b/src/vs/workbench/contrib/userData/browser/userDataPreviewEditorContribution.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import * as editorCommon from 'vs/editor/common/editorCommon'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { FloatingClickWidget } from 'vs/workbench/browser/parts/editor/editorWidgets'; +import { localize } from 'vs/nls'; +import { IUserDataSyncService, SETTINGS_PREVIEW_RESOURCE } from 'vs/workbench/services/userData/common/userData'; +import { isEqual } from 'vs/base/common/resources'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; + +export class AcceptChangesController extends Disposable implements editorCommon.IEditorContribution { + + private static readonly ID = 'editor.contrib.sync.acceptChanges'; + + static get(editor: ICodeEditor): AcceptChangesController { + return editor.getContribution(AcceptChangesController.ID); + } + + private readonly acceptChangesWidgetRenderer: MutableDisposable; + + constructor( + private editor: ICodeEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService + ) { + super(); + + this.acceptChangesWidgetRenderer = this._register(new MutableDisposable()); + this._register(this.editor.onDidChangeModel(() => this.update())); + this.update(); + } + + getId(): string { + return AcceptChangesController.ID; + } + + private update(): void { + if (this.isInterestingEditorModel()) { + if (!this.acceptChangesWidgetRenderer.value) { + this.acceptChangesWidgetRenderer.value = this.instantiationService.createInstance(AcceptChangesWidgetRenderer, this.editor); + } + } else { + this.acceptChangesWidgetRenderer.clear(); + } + } + + private isInterestingEditorModel(): boolean { + const model = this.editor.getModel(); + if (!model) { + return false; + } + return isEqual(model.uri, SETTINGS_PREVIEW_RESOURCE, false); + } +} + +export class AcceptChangesWidgetRenderer extends Disposable { + + constructor( + private readonly editor: ICodeEditor, + @IInstantiationService instantiationService: IInstantiationService, + @IUserDataSyncService private readonly userDataSyncService: IUserDataSyncService, + @IEditorService private readonly editorService: IEditorService, + @ITextFileService private readonly textFileService: ITextFileService, + @INotificationService private readonly notificationService: INotificationService + ) { + super(); + + const floatingClickWidget = this._register(instantiationService.createInstance(FloatingClickWidget, editor, localize('Accept', "Accept & Sync"), null)); + this._register(floatingClickWidget.onClick(() => this.acceptChanges())); + floatingClickWidget.render(); + } + + private async acceptChanges(): Promise { + // Do not accept if editor is readonly + if (this.editor.getOption(EditorOption.readOnly)) { + return; + } + + const model = this.editor.getModel(); + if (model) { + // Disable updating + this.editor.updateOptions({ readOnly: true }); + // Save the preview + await this.textFileService.save(model.uri); + + try { + // Apply Preview + await this.userDataSyncService.apply(model.uri); + } catch (error) { + this.notificationService.error(error); + // Enable updating + this.editor.updateOptions({ readOnly: false }); + return; + } + + // Close all preview editors + const editorInputs = this.editorService.editors.filter(input => isEqual(input.getResource(), model.uri)); + for (const input of editorInputs) { + input.dispose(); + } + } + + } +} diff --git a/src/vs/workbench/services/userData/common/settingsSync.ts b/src/vs/workbench/services/userData/common/settingsSync.ts index e79f61b3598e9..f8b6b35d144b5 100644 --- a/src/vs/workbench/services/userData/common/settingsSync.ts +++ b/src/vs/workbench/services/userData/common/settingsSync.ts @@ -8,9 +8,9 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { IFileService, FileSystemProviderErrorCode, FileSystemProviderError, IFileContent } from 'vs/platform/files/common/files'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; -import { IRemoteUserDataService, IUserData, RemoteUserDataError, RemoteUserDataErrorCode, ISynchroniser, SyncStatus } from 'vs/workbench/services/userData/common/userData'; +import { IRemoteUserDataService, IUserData, RemoteUserDataError, RemoteUserDataErrorCode, ISynchroniser, SyncStatus, SETTINGS_PREVIEW_RESOURCE } from 'vs/workbench/services/userData/common/userData'; import { VSBuffer } from 'vs/base/common/buffer'; -import { parse, findNodeAtLocation, parseTree } from 'vs/base/common/json'; +import { parse, findNodeAtLocation, parseTree, ParseError } from 'vs/base/common/json'; import { URI } from 'vs/base/common/uri'; import { ITextModel } from 'vs/editor/common/model'; import { IModelService } from 'vs/editor/common/services/modelService'; @@ -24,8 +24,8 @@ import { EditOperation } from 'vs/editor/common/core/editOperation'; import { Emitter, Event } from 'vs/base/common/event'; import { ILogService } from 'vs/platform/log/common/log'; import { Position } from 'vs/editor/common/core/position'; -import { InMemoryFileSystemProvider } from 'vs/workbench/services/userData/common/inMemoryUserDataProvider'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; +import { isEqual } from 'vs/base/common/resources'; interface ISyncPreviewResult { readonly fileContent: IFileContent | null; @@ -35,14 +35,11 @@ interface ISyncPreviewResult { readonly hasConflicts: boolean; } -export class SettingsSyncService extends Disposable implements ISynchroniser { - _serviceBrand: undefined; +export class SettingsSynchroniser extends Disposable implements ISynchroniser { private static LAST_SYNC_SETTINGS_STORAGE_KEY: string = 'LAST_SYNC_SETTINGS_CONTENTS'; private static EXTERNAL_USER_DATA_SETTINGS_KEY: string = 'settings'; - private readonly settingsPreviewResource: URI; - private syncPreviewResultPromise: Promise | null = null; private _status: SyncStatus = SyncStatus.Idle; @@ -62,10 +59,6 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { @IHistoryService private readonly historyService: IHistoryService, ) { super(); - - const settingsPreviewScheme = 'vscode-in-memory'; - this.settingsPreviewResource = URI.file('Settings-Preview').with({ scheme: settingsPreviewScheme }); - this.fileService.registerProvider(settingsPreviewScheme, new InMemoryFileSystemProvider()); } private setStatus(status: SyncStatus): void { @@ -89,7 +82,7 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { this.setStatus(SyncStatus.HasConflicts); return false; } - await this.apply(); + await this.apply(SETTINGS_PREVIEW_RESOURCE); return true; } catch (e) { this.syncPreviewResultPromise = null; @@ -108,28 +101,39 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { } } - resolveConflicts(): void { - if (this.status === SyncStatus.HasConflicts) { - const resourceInput = { - resource: this.settingsPreviewResource, - label: localize('settings preview', "Settings Preview"), - options: { - preserveFocus: false, - pinned: false, - revealIfVisible: true, - }, - mode: 'jsonc' - }; - this.editorService.openEditor(resourceInput).then(() => this.historyService.remove(resourceInput)); + handleConflicts(): boolean { + if (this.status !== SyncStatus.HasConflicts) { + return false; } + const resourceInput = { + resource: SETTINGS_PREVIEW_RESOURCE, + label: localize('Settings Conflicts', "Local ↔ Remote (Settings Conflicts)"), + options: { + preserveFocus: false, + pinned: false, + revealIfVisible: true, + }, + mode: 'jsonc' + }; + this.editorService.openEditor(resourceInput).then(() => this.historyService.remove(resourceInput)); + return true; } - async apply(): Promise { + async apply(previewResource: URI): Promise { + if (!isEqual(previewResource, SETTINGS_PREVIEW_RESOURCE, false)) { + return false; + } if (this.syncPreviewResultPromise) { const result = await this.syncPreviewResultPromise; let remoteUserData = result.remoteUserData; - const settingsPreivew = await this.fileService.readFile(this.settingsPreviewResource); + const settingsPreivew = await this.fileService.readFile(SETTINGS_PREVIEW_RESOURCE); const content = settingsPreivew.value.toString(); + + const parseErrors: ParseError[] = []; + parse(content, parseErrors); + if (parseErrors.length > 0) { + return Promise.reject(localize('errorInvalidSettings', "Unable to sync settings. Please fix errors/warnings in it and try again.")); + } if (result.hasRemoteChanged) { const ref = await this.writeToRemote(content, remoteUserData ? remoteUserData.ref : null); remoteUserData = { ref, content }; @@ -143,6 +147,7 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { } this.syncPreviewResultPromise = null; this.setStatus(SyncStatus.Idle); + return true; } private getPreview(): Promise { @@ -153,12 +158,12 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { } private async generatePreview(): Promise { - const remoteUserData = await this.remoteUserDataService.read(SettingsSyncService.EXTERNAL_USER_DATA_SETTINGS_KEY); + const remoteUserData = await this.remoteUserDataService.read(SettingsSynchroniser.EXTERNAL_USER_DATA_SETTINGS_KEY); // Get file content last to get the latest const fileContent = await this.getLocalFileContent(); const { settingsPreview, hasLocalChanged, hasRemoteChanged, hasConflicts } = await this.computeChanges(fileContent, remoteUserData); if (hasLocalChanged || hasRemoteChanged) { - await this.fileService.writeFile(this.settingsPreviewResource, VSBuffer.fromString(settingsPreview)); + await this.fileService.writeFile(SETTINGS_PREVIEW_RESOURCE, VSBuffer.fromString(settingsPreview)); } return { fileContent, remoteUserData, hasLocalChanged, hasRemoteChanged, hasConflicts }; } @@ -248,7 +253,7 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { } private getLastSyncUserData(): IUserData | null { - const lastSyncStorageContents = this.storageService.get(SettingsSyncService.LAST_SYNC_SETTINGS_STORAGE_KEY, StorageScope.GLOBAL, undefined); + const lastSyncStorageContents = this.storageService.get(SettingsSynchroniser.LAST_SYNC_SETTINGS_STORAGE_KEY, StorageScope.GLOBAL, undefined); if (lastSyncStorageContents) { return JSON.parse(lastSyncStorageContents); } @@ -425,7 +430,7 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { } private async writeToRemote(content: string, ref: string | null): Promise { - return this.remoteUserDataService.write(SettingsSyncService.EXTERNAL_USER_DATA_SETTINGS_KEY, content, ref); + return this.remoteUserDataService.write(SettingsSynchroniser.EXTERNAL_USER_DATA_SETTINGS_KEY, content, ref); } private async writeToLocal(newContent: string, oldContent: IFileContent | null): Promise { @@ -439,7 +444,7 @@ export class SettingsSyncService extends Disposable implements ISynchroniser { } private updateLastSyncValue(remoteUserData: IUserData): void { - this.storageService.store(SettingsSyncService.LAST_SYNC_SETTINGS_STORAGE_KEY, JSON.stringify(remoteUserData), StorageScope.GLOBAL); + this.storageService.store(SettingsSynchroniser.LAST_SYNC_SETTINGS_STORAGE_KEY, JSON.stringify(remoteUserData), StorageScope.GLOBAL); } } diff --git a/src/vs/workbench/services/userData/common/userData.ts b/src/vs/workbench/services/userData/common/userData.ts index 52874ff1d5678..85ca17f02e77a 100644 --- a/src/vs/workbench/services/userData/common/userData.ts +++ b/src/vs/workbench/services/userData/common/userData.ts @@ -5,6 +5,7 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { Event } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; export interface IUserData { ref: string; @@ -93,20 +94,19 @@ export enum SyncStatus { HasConflicts = 'hasConflicts', } +export const USER_DATA_PREVIEW_SCHEME = 'vscode-userdata-preview'; +export const SETTINGS_PREVIEW_RESOURCE = URI.file('Settings-Preview').with({ scheme: USER_DATA_PREVIEW_SCHEME }); + export interface ISynchroniser { readonly status: SyncStatus; readonly onDidChangeStatus: Event; sync(): Promise; - resolveConflicts(): void; - apply(): void; + handleConflicts(): boolean; + apply(previewResource: URI): Promise; } export const IUserDataSyncService = createDecorator('IUserDataSyncService'); -export interface IUserDataSyncService { +export interface IUserDataSyncService extends ISynchroniser { _serviceBrand: any; - readonly status: SyncStatus; - readonly onDidChangeStatus: Event; - sync(): Promise; - resolveConflicts(): void; } diff --git a/src/vs/workbench/services/userData/common/userDataSyncService.ts b/src/vs/workbench/services/userData/common/userDataSyncService.ts index 9a19cac1d241c..d255a68111f28 100644 --- a/src/vs/workbench/services/userData/common/userDataSyncService.ts +++ b/src/vs/workbench/services/userData/common/userDataSyncService.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IUserDataSyncService, SyncStatus, IRemoteUserDataService, ISynchroniser } from 'vs/workbench/services/userData/common/userData'; +import { IUserDataSyncService, SyncStatus, IRemoteUserDataService, ISynchroniser, USER_DATA_PREVIEW_SCHEME } from 'vs/workbench/services/userData/common/userData'; import { Disposable } from 'vs/base/common/lifecycle'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { SettingsSyncService as SettingsSynchroniser } from 'vs/workbench/services/userData/common/settingsSync'; +import { SettingsSynchroniser } from 'vs/workbench/services/userData/common/settingsSync'; import { Emitter, Event } from 'vs/base/common/event'; +import { URI } from 'vs/base/common/uri'; +import { IFileService } from 'vs/platform/files/common/files'; +import { InMemoryFileSystemProvider } from 'vs/workbench/services/userData/common/inMemoryUserDataProvider'; export class UserDataSyncService extends Disposable implements IUserDataSyncService { @@ -22,10 +25,12 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ readonly onDidChangeStatus: Event = this._onDidChangStatus.event; constructor( + @IFileService fileService: IFileService, @IRemoteUserDataService private readonly remoteUserDataService: IRemoteUserDataService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); + this._register(fileService.registerProvider(USER_DATA_PREVIEW_SCHEME, new InMemoryFileSystemProvider())); this.synchronisers = [ this.instantiationService.createInstance(SettingsSynchroniser) ]; @@ -33,22 +38,40 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ this._register(Event.any(this.remoteUserDataService.onDidChangeEnablement, ...this.synchronisers.map(s => Event.map(s.onDidChangeStatus, () => undefined)))(() => this.updateStatus())); } - async sync(): Promise { + async sync(): Promise { if (!this.remoteUserDataService.isEnabled()) { throw new Error('Not enabled'); } for (const synchroniser of this.synchronisers) { if (!await synchroniser.sync()) { - return; + return false; } } + return true; } - resolveConflicts(): void { - const synchroniserWithConflicts = this.synchronisers.filter(s => s.status === SyncStatus.HasConflicts)[0]; - if (synchroniserWithConflicts) { - synchroniserWithConflicts.resolveConflicts(); + async apply(previewResource: URI): Promise { + if (!this.remoteUserDataService.isEnabled()) { + throw new Error('Not enabled'); + } + for (const synchroniser of this.synchronisers) { + if (await synchroniser.apply(previewResource)) { + return true; + } + } + return false; + } + + handleConflicts(): boolean { + if (!this.remoteUserDataService.isEnabled()) { + throw new Error('Not enabled'); + } + for (const synchroniser of this.synchronisers) { + if (synchroniser.handleConflicts()) { + return true; + } } + return false; } private updateStatus(): void { diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index c0601847c29a5..f00a72295995c 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -240,6 +240,6 @@ import 'vs/workbench/contrib/experiments/browser/experiments.contribution'; import 'vs/workbench/contrib/feedback/browser/feedback.contribution'; // User Data -import 'vs/workbench/contrib/userData/common/userData.contribution'; +import 'vs/workbench/contrib/userData/browser/userData.contribution'; //#endregion