Implement export/import profiles

- Introduce workbench profile service
- Implement settings, global state, extension profiles
- Implement import/export profile actions
This commit is contained in:
Sandeep Somavarapu 2022-04-22 23:51:05 +05:30
parent b7774c843e
commit 5b242ed4ff
No known key found for this signature in database
GPG Key ID: DD41CAAC8081CC7D
12 changed files with 506 additions and 0 deletions

View File

@ -449,6 +449,10 @@
{
"name": "vs/workbench/services/host",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/contrib/profiles",
"project": "vscode-profiles"
}
]
}

View File

@ -54,6 +54,11 @@
"filenamePatterns": [
"**/User/snippets/*.json"
]
}, {
"id": "json",
"extensions": [
".code-profile"
]
}
],
"jsonValidation": [

View File

@ -57,6 +57,16 @@ function getIgnoredSettingsFromContent(settingsContent: string): string[] {
return parsed ? parsed['settingsSync.ignoredSettings'] || parsed['sync.ignoredSettings'] || [] : [];
}
export function removeComments(content: string, formattingOptions: FormattingOptions): string {
const source = parse(content) || {};
let result = '{}';
for (const key of Object.keys(source)) {
const edits = setProperty(result, [key], source[key], formattingOptions);
result = applyEdits(result, edits);
}
return result;
}
export function updateIgnoredSettings(targetContent: string, sourceContent: string, ignoredSettings: string[], formattingOptions: FormattingOptions): string {
if (ignoredSettings.length) {
const sourceTree = parseSettings(sourceContent);

View File

@ -0,0 +1,6 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './profilesActions';

View File

@ -0,0 +1,10 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { localize } from 'vs/nls';
export const PROFILES_CATEGORY = localize('profiles', "Profiles");
export const PROFILE_EXTENSION = 'code-profile';
export const PROFILE_FILTER = [{ name: localize('profile', "Code Profile"), extensions: [PROFILE_EXTENSION] }];

View File

@ -0,0 +1,137 @@
/*---------------------------------------------------------------------------------------------
* 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 { DisposableStore } from 'vs/base/common/lifecycle';
import { joinPath } from 'vs/base/common/resources';
import { localize } from 'vs/nls';
import { Action2, registerAction2 } from 'vs/platform/actions/common/actions';
import { IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IFileService } from 'vs/platform/files/common/files';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput';
import { asJson, asText, IRequestService } from 'vs/platform/request/common/request';
import { PROFILES_CATEGORY, PROFILE_EXTENSION, PROFILE_FILTER } from 'vs/workbench/contrib/profiles/common/profiles';
import { IProfile, isProfile, IWorkbenchProfileService } from 'vs/workbench/services/profiles/common/profile';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
registerAction2(class ExportProfileAction extends Action2 {
constructor() {
super({
id: 'workbench.profiles.actions.exportProfile',
title: {
value: localize('export profile', "Export customizations as a Profile..."),
original: 'Export customizations as a Profile...'
},
category: PROFILES_CATEGORY,
f1: true
});
}
async run(accessor: ServicesAccessor) {
const textFileService = accessor.get(ITextFileService);
const fileDialogService = accessor.get(IFileDialogService);
const profileService = accessor.get(IWorkbenchProfileService);
const notificationService = accessor.get(INotificationService);
const profileLocation = await fileDialogService.showSaveDialog({
title: localize('export profile dialog', "Save Profile"),
filters: PROFILE_FILTER,
defaultUri: joinPath(await fileDialogService.defaultFilePath(undefined), `profile.${PROFILE_EXTENSION}`),
});
if (!profileLocation) {
return;
}
const profile = await profileService.createProfile({ skipComments: true });
await textFileService.create([{ resource: profileLocation, value: JSON.stringify(profile), options: { overwrite: true } }]);
notificationService.info(localize('export success', "Profile successfully exported."));
}
});
registerAction2(class ImportProfileAction extends Action2 {
constructor() {
super({
id: 'workbench.profiles.actions.importProfile',
title: {
value: localize('import profile', "Import customizations from a Profile..."),
original: 'Import customizations from a Profile...'
},
category: PROFILES_CATEGORY,
f1: true
});
}
async run(accessor: ServicesAccessor) {
const fileDialogService = accessor.get(IFileDialogService);
const quickInputService = accessor.get(IQuickInputService);
const fileService = accessor.get(IFileService);
const requestService = accessor.get(IRequestService);
const profileService = accessor.get(IWorkbenchProfileService);
const dialogService = accessor.get(IDialogService);
if (!(await dialogService.confirm({
title: localize('import profile title', "Import customizations from a Profile"),
message: localize('confiirmation message', "This will replace your current customizations. Are you sure you want to continue?"),
})).confirmed) {
return;
}
const disposables = new DisposableStore();
const quickPick = disposables.add(quickInputService.createQuickPick());
const updateQuickPickItems = (value?: string) => {
const selectFromFileItem: IQuickPickItem = { label: localize('select from file', "Import from profile file") };
quickPick.items = value ? [{ label: localize('select from url', "Import from URL"), description: quickPick.value }, selectFromFileItem] : [selectFromFileItem];
};
quickPick.title = localize('import profile quick pick title', "Import customizations from a Profile");
quickPick.placeholder = localize('import profile placeholder', "Provide profile URL or select profile file to import");
quickPick.ignoreFocusOut = true;
disposables.add(quickPick.onDidChangeValue(updateQuickPickItems));
updateQuickPickItems();
quickPick.matchOnLabel = false;
quickPick.matchOnDescription = false;
disposables.add(quickPick.onDidAccept(async () => {
quickPick.hide();
const profile = quickPick.selectedItems[0].description ? await this.getProfileFromURL(quickPick.value, requestService) : await this.getProfileFromFileSystem(fileDialogService, fileService);
if (profile) {
await profileService.setProfile(profile);
}
}));
disposables.add(quickPick.onDidHide(() => disposables.dispose()));
quickPick.show();
}
private async getProfileFromFileSystem(fileDialogService: IFileDialogService, fileService: IFileService): Promise<IProfile | null> {
const profileLocation = await fileDialogService.showOpenDialog({
canSelectFolders: false,
canSelectFiles: true,
canSelectMany: false,
filters: PROFILE_FILTER,
title: localize('import profile dialog', "Import Profile"),
});
if (!profileLocation) {
return null;
}
const content = (await fileService.readFile(profileLocation[0])).value.toString();
const parsed = JSON.parse(content);
return isProfile(parsed) ? parsed : null;
}
private async getProfileFromURL(url: string, requestService: IRequestService): Promise<IProfile | null> {
const options = { type: 'GET', url };
const context = await requestService.request(options, CancellationToken.None);
if (context.res.statusCode === 200) {
const result = await asJson(context);
return isProfile(result) ? result : null;
} else {
const message = await asText(context);
throw new Error(`Expected 200, got back ${context.res.statusCode} instead.\n\n${message}`);
}
}
});

View File

@ -0,0 +1,102 @@
/*---------------------------------------------------------------------------------------------
* 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 { IExtensionGalleryService, IExtensionIdentifier, IExtensionManagementService, ILocalExtension } from 'vs/platform/extensionManagement/common/extensionManagement';
import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil';
import { ExtensionType } from 'vs/platform/extensions/common/extensions';
import { ILogService } from 'vs/platform/log/common/log';
import { EnablementState, IWorkbenchExtensionEnablementService } from 'vs/workbench/services/extensionManagement/common/extensionManagement';
import { IResourceProfile } from 'vs/workbench/services/profiles/common/profile';
interface IProfileExtension {
identifier: IExtensionIdentifier;
preRelease?: boolean;
disabled?: boolean;
}
export class ExtensionsProfile implements IResourceProfile {
constructor(
@IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService,
@IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService,
@IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService,
@ILogService private readonly logService: ILogService,
) {
}
async getProfileContent(): Promise<string> {
const extensions = await this.getLocalExtensions();
return JSON.stringify(extensions);
}
async applyProfile(content: string): Promise<void> {
const profileExtensions: IProfileExtension[] = JSON.parse(content);
const installedExtensions = await this.extensionManagementService.getInstalled();
const extensionsToEnableOrDisable: { extension: ILocalExtension; enablementState: EnablementState }[] = [];
const extensionsToInstall: IProfileExtension[] = [];
for (const e of profileExtensions) {
const installedExtension = installedExtensions.find(installed => areSameExtensions(installed.identifier, e.identifier));
if (!installedExtension || installedExtension.preRelease !== e.preRelease) {
extensionsToInstall.push(e);
}
if (installedExtension && this.extensionEnablementService.isEnabled(installedExtension) !== !e.disabled) {
extensionsToEnableOrDisable.push({ extension: installedExtension, enablementState: e.disabled ? EnablementState.DisabledGlobally : EnablementState.EnabledGlobally });
}
}
const extensionsToUninstall: ILocalExtension[] = installedExtensions.filter(extension => extension.type === ExtensionType.User && !profileExtensions.some(({ identifier }) => areSameExtensions(identifier, extension.identifier)));
for (const { extension, enablementState } of extensionsToEnableOrDisable) {
this.logService.trace(`Profile: Updating extension enablement...`, extension.identifier.id);
await this.extensionEnablementService.setEnablement([extension], enablementState);
this.logService.info(`Profile: Updated extension enablement`, extension.identifier.id);
}
if (extensionsToInstall.length) {
const galleryExtensions = await this.extensionGalleryService.getExtensions(extensionsToInstall.map(e => ({ ...e.identifier, hasPreRelease: e.preRelease })), CancellationToken.None);
await Promise.all(extensionsToInstall.map(async e => {
const extension = galleryExtensions.find(galleryExtension => areSameExtensions(galleryExtension.identifier, e.identifier));
if (!extension) {
return;
}
if (await this.extensionManagementService.canInstall(extension)) {
this.logService.trace(`Profile: Installing extension...`, e.identifier.id, extension.version);
await this.extensionManagementService.installFromGallery(extension, { isMachineScoped: false, donotIncludePackAndDependencies: true, installPreReleaseVersion: e.preRelease } /* set isMachineScoped value to prevent install and sync dialog in web */);
this.logService.info(`Profile: Installed extension.`, e.identifier.id, extension.version);
} else {
this.logService.info(`Profile: Skipped installing extension because it cannot be installed.`, extension.displayName || extension.identifier.id);
}
}));
}
if (extensionsToUninstall.length) {
await Promise.all(extensionsToUninstall.map(e => this.extensionManagementService.uninstall(e)));
}
}
private async getLocalExtensions(): Promise<IProfileExtension[]> {
const result: IProfileExtension[] = [];
const installedExtensions = await this.extensionManagementService.getInstalled(undefined, true);
for (const extension of installedExtensions) {
const { identifier, preRelease } = extension;
const enablementState = this.extensionEnablementService.getEnablementState(extension);
const disabled = !this.extensionEnablementService.isEnabledEnablementState(enablementState);
if (!disabled && extension.type === ExtensionType.System) {
// skip enabled system extensions
continue;
}
if (disabled && enablementState !== EnablementState.DisabledGlobally && enablementState !== EnablementState.DisabledWorkspace) {
//skip extensions that are not disabled by user
continue;
}
const profileExtension: IProfileExtension = { identifier };
if (disabled) {
profileExtension.disabled = true;
}
if (preRelease) {
profileExtension.preRelease = true;
}
result.push(profileExtension);
}
return result;
}
}

View File

@ -0,0 +1,62 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IStringDictionary } from 'vs/base/common/collections';
import { ILogService } from 'vs/platform/log/common/log';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { IResourceProfile } from 'vs/workbench/services/profiles/common/profile';
interface IGlobalState {
storage: IStringDictionary<string>;
}
export class GlobalStateProfile implements IResourceProfile {
constructor(
@IStorageService private readonly storageService: IStorageService,
@ILogService private readonly logService: ILogService,
) {
}
async getProfileContent(): Promise<string> {
const globalState = await this.getLocalGlobalState();
return JSON.stringify(globalState);
}
async applyProfile(content: string): Promise<void> {
const globalState: IGlobalState = JSON.parse(content);
await this.writeLocalGlobalState(globalState);
}
private async getLocalGlobalState(): Promise<IGlobalState> {
const storage: IStringDictionary<string> = {};
for (const key of this.storageService.keys(StorageScope.GLOBAL, StorageTarget.PROFILE)) {
const value = this.storageService.get(key, StorageScope.GLOBAL);
if (value) {
storage[key] = value;
}
}
return { storage };
}
private async writeLocalGlobalState(globalState: IGlobalState): Promise<void> {
const profileKeys: string[] = Object.keys(globalState.storage);
const updatedStorage: IStringDictionary<any> = globalState.storage;
for (const key of this.storageService.keys(StorageScope.GLOBAL, StorageTarget.PROFILE)) {
if (!profileKeys.includes(key)) {
// Remove the key if it does not exist in the profile
updatedStorage[key] = undefined;
}
}
const updatedStorageKeys: string[] = Object.keys(updatedStorage);
if (updatedStorageKeys.length) {
this.logService.trace(`Profile: Updating global state...`);
for (const key of updatedStorageKeys) {
this.storageService.store(key, globalState.storage[key], StorageScope.GLOBAL, StorageTarget.PROFILE);
}
this.logService.info(`Profile: Updated global state`, updatedStorageKeys);
}
}
}

View File

@ -0,0 +1,41 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isUndefined } from 'vs/base/common/types';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
export interface IProfile {
readonly id: string;
readonly name?: string;
readonly settings?: string;
readonly globalState?: string;
readonly extensions?: string;
}
export function isProfile(thing: any): thing is IProfile {
const candidate = thing as IProfile | undefined;
return !!(candidate && typeof candidate === 'object'
&& typeof candidate.id === 'string'
&& (isUndefined(candidate.name) || typeof candidate.name === 'string')
&& (isUndefined(candidate.settings) || typeof candidate.settings === 'string')
&& (isUndefined(candidate.globalState) || typeof candidate.globalState === 'string')
&& (isUndefined(candidate.extensions) || typeof candidate.extensions === 'string'));
}
export type ProfileCreationOptions = { readonly skipComments: boolean };
export const IWorkbenchProfileService = createDecorator<IWorkbenchProfileService>('IWorkbenchProfileService');
export interface IWorkbenchProfileService {
readonly _serviceBrand: undefined;
createProfile(options?: ProfileCreationOptions): Promise<IProfile>;
setProfile(profile: IProfile): Promise<void>;
}
export interface IResourceProfile {
getProfileContent(): Promise<string>;
applyProfile(content: string): Promise<void>;
}

View File

@ -0,0 +1,56 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { generateUuid } from 'vs/base/common/uuid';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ExtensionsProfile } from 'vs/workbench/services/profiles/common/extensionsProfile';
import { GlobalStateProfile } from 'vs/workbench/services/profiles/common/globalStateProfile';
import { IProfile, IWorkbenchProfileService } from 'vs/workbench/services/profiles/common/profile';
import { SettingsProfile } from 'vs/workbench/services/profiles/common/settingsProfile';
export class WorkbenchProfileService implements IWorkbenchProfileService {
readonly _serviceBrand: undefined;
private readonly settingsProfile: SettingsProfile;
private readonly globalStateProfile: GlobalStateProfile;
private readonly extensionsProfile: ExtensionsProfile;
constructor(
@IInstantiationService instantiationService: IInstantiationService
) {
this.settingsProfile = instantiationService.createInstance(SettingsProfile);
this.globalStateProfile = instantiationService.createInstance(GlobalStateProfile);
this.extensionsProfile = instantiationService.createInstance(ExtensionsProfile);
}
async createProfile(options?: { skipComments: boolean }): Promise<IProfile> {
const settings = await this.settingsProfile.getProfileContent(options);
const globalState = await this.globalStateProfile.getProfileContent();
const extensions = await this.extensionsProfile.getProfileContent();
return {
id: generateUuid(),
settings,
globalState,
extensions
};
}
async setProfile(profile: IProfile): Promise<void> {
if (profile.settings) {
await this.settingsProfile.applyProfile(profile.settings);
}
if (profile.globalState) {
await this.globalStateProfile.applyProfile(profile.globalState);
}
if (profile.extensions) {
await this.extensionsProfile.applyProfile(profile.extensions);
}
}
}
registerSingleton(IWorkbenchProfileService, WorkbenchProfileService);

View File

@ -0,0 +1,69 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { VSBuffer } from 'vs/base/common/buffer';
import { ConfigurationScope, Extensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { IFileService } from 'vs/platform/files/common/files';
import { ILogService } from 'vs/platform/log/common/log';
import { Registry } from 'vs/platform/registry/common/platform';
import { removeComments, updateIgnoredSettings } from 'vs/platform/userDataSync/common/settingsMerge';
import { IUserDataSyncUtilService } from 'vs/platform/userDataSync/common/userDataSync';
import { IResourceProfile, ProfileCreationOptions } from 'vs/workbench/services/profiles/common/profile';
interface ISettingsContent {
settings: string;
}
export class SettingsProfile implements IResourceProfile {
constructor(
@IFileService private readonly fileService: IFileService,
@IEnvironmentService private readonly environmentService: IEnvironmentService,
@IUserDataSyncUtilService private readonly userDataSyncUtilService: IUserDataSyncUtilService,
@ILogService private readonly logService: ILogService,
) {
}
async getProfileContent(options?: ProfileCreationOptions): Promise<string> {
const ignoredSettings = this.getIgnoredSettings();
const formattingOptions = await this.userDataSyncUtilService.resolveFormattingOptions(this.environmentService.settingsResource);
const localContent = await this.getLocalFileContent();
let settingsProfileContent = updateIgnoredSettings(localContent || '{}', '{}', ignoredSettings, formattingOptions);
if (options?.skipComments) {
settingsProfileContent = removeComments(settingsProfileContent, formattingOptions);
}
const settingsContent: ISettingsContent = {
settings: settingsProfileContent
};
return JSON.stringify(settingsContent);
}
async applyProfile(content: string): Promise<void> {
const settingsContent: ISettingsContent = JSON.parse(content);
this.logService.trace(`Profile: Applying settings...`);
const localSettingsContent = await this.getLocalFileContent();
const formattingOptions = await this.userDataSyncUtilService.resolveFormattingOptions(this.environmentService.settingsResource);
const contentToUpdate = updateIgnoredSettings(settingsContent.settings, localSettingsContent || '{}', this.getIgnoredSettings(), formattingOptions);
await this.fileService.writeFile(this.environmentService.settingsResource, VSBuffer.fromString(contentToUpdate));
this.logService.info(`Profile: Applied settings`);
}
private getIgnoredSettings(): string[] {
const allSettings = Registry.as<IConfigurationRegistry>(Extensions.Configuration).getConfigurationProperties();
const ignoredSettings = Object.keys(allSettings).filter(key => allSettings[key]?.scope === ConfigurationScope.MACHINE || allSettings[key]?.scope === ConfigurationScope.MACHINE_OVERRIDABLE);
return ignoredSettings;
}
private async getLocalFileContent(): Promise<string | null> {
try {
const content = await this.fileService.readFile(this.environmentService.settingsResource);
return content.value.toString();
} catch (error) {
return null;
}
}
}

View File

@ -83,6 +83,7 @@ import 'vs/workbench/services/extensionRecommendations/common/extensionIgnoredRe
import 'vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig';
import 'vs/workbench/services/notification/common/notificationService';
import 'vs/workbench/services/userDataSync/common/userDataSyncUtil';
import 'vs/workbench/services/profiles/common/profileService';
import 'vs/workbench/services/remote/common/remoteExplorerService';
import 'vs/workbench/services/workingCopy/common/workingCopyService';
import 'vs/workbench/services/workingCopy/common/workingCopyFileService';
@ -315,6 +316,9 @@ import 'vs/workbench/contrib/feedback/browser/feedback.contribution';
// User Data Sync
import 'vs/workbench/contrib/userDataSync/browser/userDataSync.contribution';
// Profiles
import 'vs/workbench/contrib/profiles/common/profiles.contribution';
// Code Actions
import 'vs/workbench/contrib/codeActions/browser/codeActions.contribution';