diff --git a/extensions/github/package.json b/extensions/github/package.json index bdd04a2e4c8..a3b81f2bf78 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -13,7 +13,9 @@ "Other" ], "activationEvents": [ - "*" + "*", + "onProfile", + "onProfile:github" ], "extensionDependencies": [ "vscode.git-base" @@ -27,7 +29,8 @@ }, "enabledApiProposals": [ "contribShareMenu", - "contribEditSessions" + "contribEditSessions", + "profileContentHandlers" ], "contributes": { "commands": [ diff --git a/extensions/github/src/extension.ts b/extensions/github/src/extension.ts index a3a84b033dd..d752b05cc36 100644 --- a/extensions/github/src/extension.ts +++ b/extensions/github/src/extension.ts @@ -12,6 +12,7 @@ import { DisposableStore, repositoryHasGitHubRemote } from './util'; import { GithubPushErrorHandler } from './pushErrorHandler'; import { GitBaseExtension } from './typings/git-base'; import { GithubRemoteSourcePublisher } from './remoteSourcePublisher'; +import './importExportProfiles'; export function activate(context: ExtensionContext): void { context.subscriptions.push(initializeGitBaseExtension()); diff --git a/extensions/github/src/importExportProfiles.ts b/extensions/github/src/importExportProfiles.ts new file mode 100644 index 00000000000..2b4cc81ba34 --- /dev/null +++ b/extensions/github/src/importExportProfiles.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Octokit } from '@octokit/rest'; +import * as vscode from 'vscode'; +import { httpsOverHttp } from 'tunnel'; +import { Agent, globalAgent } from 'https'; +import { basename } from 'path'; +import { URL } from 'url'; + +class GitHubGistProfileContentHandler implements vscode.ProfileContentHandler { + + readonly name = vscode.l10n.t('GitHub'); + + private _octokit: Promise | undefined; + private getOctokit(): Promise { + if (!this._octokit) { + this._octokit = (async () => { + const session = await vscode.authentication.getSession('github', ['gist', 'user:email'], { createIfNone: true }); + const token = session.accessToken; + const agent = this.getAgent(); + + const { Octokit } = await import('@octokit/rest'); + + return new Octokit({ + request: { agent }, + userAgent: 'GitHub VSCode', + auth: `token ${token}` + }); + })(); + } + return this._octokit; + } + + private getAgent(url: string | undefined = process.env.HTTPS_PROXY): Agent { + if (!url) { + return globalAgent; + } + try { + const { hostname, port, username, password } = new URL(url); + const auth = username && password && `${username}:${password}`; + return httpsOverHttp({ proxy: { host: hostname, port, proxyAuth: auth } }); + } catch (e) { + vscode.window.showErrorMessage(`HTTPS_PROXY environment variable ignored: ${e.message}`); + return globalAgent; + } + } + + async saveProfile(name: string, content: string): Promise { + const octokit = await this.getOctokit(); + const result = await octokit.gists.create({ + public: true, + files: { + [name]: { + content + } + } + }); + return result.data.html_url ? vscode.Uri.parse(result.data.html_url) : null; + } + + async readProfile(uri: vscode.Uri): Promise { + const gist_id = basename(uri.path); + const octokit = await this.getOctokit(); + try { + const gist = await octokit.gists.get({ gist_id }); + if (gist.data.files) { + return gist.data.files[Object.keys(gist.data.files)[0]]?.content ?? null; + } + } catch (error) { + // ignore + } + return null; + } + +} + +vscode.window.registerProfileContentHandler('github', new GitHubGistProfileContentHandler()); diff --git a/extensions/github/tsconfig.json b/extensions/github/tsconfig.json index d7aed1836ee..9aa8c818a66 100644 --- a/extensions/github/tsconfig.json +++ b/extensions/github/tsconfig.json @@ -9,6 +9,7 @@ }, "include": [ "src/**/*", - "../../src/vscode-dts/vscode.d.ts" + "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.profileContentHandlers.d.ts", ] } diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 38a27b2cea4..38b87dac023 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -75,6 +75,7 @@ import './mainThreadAuthentication'; import './mainThreadTimeline'; import './mainThreadTesting'; import './mainThreadSecretState'; +import './mainThreadProfilContentHandlers'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadProfilContentHandlers.ts b/src/vs/workbench/api/browser/mainThreadProfilContentHandlers.ts new file mode 100644 index 00000000000..1b36133edd2 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadProfilContentHandlers.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { ExtHostContext, ExtHostProfileContentHandlersShape, MainContext, MainThreadProfileContentHandlersShape } from 'vs/workbench/api/common/extHost.protocol'; +import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; +import { IUserDataProfileImportExportService } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; + +@extHostNamedCustomer(MainContext.MainThreadProfileContentHandlers) +export class MainThreadProfileContentHandlers extends Disposable implements MainThreadProfileContentHandlersShape { + + private readonly proxy: ExtHostProfileContentHandlersShape; + + private readonly registeredHandlers = new Set(); + + constructor( + context: IExtHostContext, + @IUserDataProfileImportExportService private readonly userDataProfileImportExportService: IUserDataProfileImportExportService, + ) { + super(); + this.proxy = context.getProxy(ExtHostContext.ExtHostProfileContentHandlers); + this._register(toDisposable(() => { + for (const id of this.registeredHandlers) { + this.userDataProfileImportExportService.unregisterProfileContentHandler(id); + } + this.registeredHandlers.clear(); + })); + } + + async $registerProfileContentHandler(id: string, name: string, extensionId: string): Promise { + this.userDataProfileImportExportService.registerProfileContentHandler(id, { + name, + extensionId, + saveProfile: async (name: string, content: string, token: CancellationToken) => { + const result = await this.proxy.$saveProfile(id, name, content, token); + return result ? URI.revive(result) : null; + }, + readProfile: async (uri: URI, token: CancellationToken) => { + return this.proxy.$readProfile(id, uri, token); + }, + }); + } + + async $unregisterProfileContentHandler(id: string): Promise { + this.userDataProfileImportExportService.unregisterProfileContentHandler(id); + } + +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 64fd5cae58d..ab663e38b9c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -95,6 +95,7 @@ import { checkProposedApiEnabled, ExtensionIdentifierSet, isProposedApiEnabled } import { DebugConfigurationProviderTriggerKind } from 'vs/workbench/contrib/debug/common/debug'; import { IExtHostLocalizationService } from 'vs/workbench/api/common/extHostLocalizationService'; import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessions'; +import { ExtHostProfileContentHandlers } from 'vs/workbench/api/common/extHostProfileContentHandler'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -186,6 +187,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); const extHostTesting = rpcProtocol.set(ExtHostContext.ExtHostTesting, new ExtHostTesting(rpcProtocol, extHostCommands, extHostDocumentsAndEditors)); const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol)); + const extHostProfileContentHandlers = rpcProtocol.set(ExtHostContext.ExtHostProfileContentHandlers, new ExtHostProfileContentHandlers(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostInteractive, new ExtHostInteractive(rpcProtocol, extHostNotebook, extHostDocumentsAndEditors, extHostCommands, extHostLogService)); // Check that no named customers are missing @@ -792,6 +794,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'externalUriOpener'); return extHostUriOpeners.registerExternalUriOpener(extension.identifier, id, opener, metadata); }, + registerProfileContentHandler(id: string, handler: vscode.ProfileContentHandler) { + checkProposedApiEnabled(extension, 'profileContentHandlers'); + return extHostProfileContentHandlers.registrProfileContentHandler(extension, id, handler); + }, get tabGroups(): vscode.TabGroups { return extHostEditorTabs.tabGroups; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 389a424dfdb..1da144a78ac 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1079,6 +1079,16 @@ export interface ExtHostUriOpenersShape { $openUri(id: string, context: { resolvedUri: UriComponents; sourceUri: UriComponents }, token: CancellationToken): Promise; } +export interface MainThreadProfileContentHandlersShape { + $registerProfileContentHandler(id: string, name: string, extensionId: string): Promise; + $unregisterProfileContentHandler(id: string): Promise; +} + +export interface ExtHostProfileContentHandlersShape { + $saveProfile(id: string, name: string, content: string, token: CancellationToken): Promise; + $readProfile(id: string, uri: UriComponents, token: CancellationToken): Promise; +} + export interface ITextSearchComplete { limitHit?: boolean; } @@ -2326,6 +2336,7 @@ export const MainContext = { MainThreadCustomEditors: createProxyIdentifier('MainThreadCustomEditors'), MainThreadUrls: createProxyIdentifier('MainThreadUrls'), MainThreadUriOpeners: createProxyIdentifier('MainThreadUriOpeners'), + MainThreadProfileContentHandlers: createProxyIdentifier('MainThreadProfileContentHandlers'), MainThreadWorkspace: createProxyIdentifier('MainThreadWorkspace'), MainThreadFileSystem: createProxyIdentifier('MainThreadFileSystem'), MainThreadExtensionService: createProxyIdentifier('MainThreadExtensionService'), @@ -2385,6 +2396,7 @@ export const ExtHostContext = { ExtHostStorage: createProxyIdentifier('ExtHostStorage'), ExtHostUrls: createProxyIdentifier('ExtHostUrls'), ExtHostUriOpeners: createProxyIdentifier('ExtHostUriOpeners'), + ExtHostProfileContentHandlers: createProxyIdentifier('ExtHostProfileContentHandlers'), ExtHostOutputService: createProxyIdentifier('ExtHostOutputService'), ExtHosLabelService: createProxyIdentifier('ExtHostLabelService'), ExtHostNotebook: createProxyIdentifier('ExtHostNotebook'), diff --git a/src/vs/workbench/api/common/extHostProfileContentHandler.ts b/src/vs/workbench/api/common/extHostProfileContentHandler.ts new file mode 100644 index 00000000000..f4c300a3bc0 --- /dev/null +++ b/src/vs/workbench/api/common/extHostProfileContentHandler.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { toDisposable } from 'vs/base/common/lifecycle'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import type * as vscode from 'vscode'; +import { ExtHostProfileContentHandlersShape, IMainContext, MainContext, MainThreadProfileContentHandlersShape } from './extHost.protocol'; + + +export class ExtHostProfileContentHandlers implements ExtHostProfileContentHandlersShape { + + private readonly proxy: MainThreadProfileContentHandlersShape; + + private readonly handlers = new Map(); + + constructor( + mainContext: IMainContext, + ) { + this.proxy = mainContext.getProxy(MainContext.MainThreadProfileContentHandlers); + } + + registrProfileContentHandler( + extension: IExtensionDescription, + id: string, + handler: vscode.ProfileContentHandler, + ): vscode.Disposable { + checkProposedApiEnabled(extension, 'profileContentHandlers'); + if (this.handlers.has(id)) { + throw new Error(`Handler with id '${id}' already registered`); + } + + this.handlers.set(id, handler); + this.proxy.$registerProfileContentHandler(id, handler.name, extension.identifier.value); + + return toDisposable(() => { + this.handlers.delete(id); + this.proxy.$unregisterProfileContentHandler(id); + }); + } + + async $saveProfile(id: string, name: string, content: string, token: CancellationToken): Promise { + const handler = this.handlers.get(id); + if (!handler) { + throw new Error(`Unknown handler with id: ${id}`); + } + + return handler.saveProfile(name, content, token); + } + + async $readProfile(id: string, uri: UriComponents, token: CancellationToken): Promise { + const handler = this.handlers.get(id); + if (!handler) { + throw new Error(`Unknown handler with id: ${id}`); + } + + return handler.readProfile(URI.revive(uri), token); + } +} diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 88ee287f9c5..ba09c1dd30e 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -50,6 +50,7 @@ export const allApiProposals = Object.freeze({ notebookMessaging: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.notebookMessaging.d.ts', notebookMime: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.notebookMime.d.ts', portsAttributes: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.portsAttributes.d.ts', + profileContentHandlers: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.profileContentHandlers.d.ts', quickPickSortByLabel: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.quickPickSortByLabel.d.ts', resolvers: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.resolvers.d.ts', scmActionButton: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmActionButton.d.ts', diff --git a/src/vs/workbench/services/userDataProfile/browser/extensionsResource.ts b/src/vs/workbench/services/userDataProfile/browser/extensionsResource.ts index b537602b3eb..b2ad33d287d 100644 --- a/src/vs/workbench/services/userDataProfile/browser/extensionsResource.ts +++ b/src/vs/workbench/services/userDataProfile/browser/extensionsResource.ts @@ -17,7 +17,7 @@ import { IStorageService } from 'vs/platform/storage/common/storage'; import { IUserDataProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; import { IUserDataProfileStorageService } from 'vs/platform/userDataProfile/common/userDataProfileStorageService'; import { ITreeItemCheckboxState, TreeItemCollapsibleState } from 'vs/workbench/common/views'; -import { IProfileResource, IProfileResourceChildTreeItem, IProfileResourceTreeItem } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { IProfileResource, IProfileResourceChildTreeItem, IProfileResourceTreeItem, ProfileResourceType } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; interface IProfileExtension { identifier: IExtensionIdentifier; @@ -39,6 +39,10 @@ export class ExtensionsResource implements IProfileResource { async getContent(profile: IUserDataProfile, exclude?: string[]): Promise { const extensions = await this.getLocalExtensions(profile); + return this.toContent(extensions, exclude); + } + + toContent(extensions: IProfileExtension[], exclude?: string[]): string { return JSON.stringify(exclude?.length ? extensions.filter(e => !exclude.includes(e.identifier.id.toLowerCase())) : extensions); } @@ -140,22 +144,18 @@ export class ExtensionsResource implements IProfileResource { } } -export class ExtensionsResourceExportTreeItem implements IProfileResourceTreeItem { +abstract class ExtensionsResourceTreeItem implements IProfileResourceTreeItem { - readonly handle = this.profile.extensionsResource.toString(); + readonly type = ProfileResourceType.Extensions; + readonly handle = ProfileResourceType.Extensions; readonly label = { label: localize('extensions', "Extensions") }; readonly collapsibleState = TreeItemCollapsibleState.Expanded; checkbox: ITreeItemCheckboxState = { isChecked: true }; - private readonly excludedExtensions = new Set(); - - constructor( - private readonly profile: IUserDataProfile, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { } + protected readonly excludedExtensions = new Set(); async getChildren(): Promise { - const extensions = await this.instantiationService.createInstance(ExtensionsResource).getLocalExtensions(this.profile); + const extensions = await this.getExtensions(); const that = this; return extensions.map(e => ({ handle: e.identifier.id.toLowerCase(), @@ -182,48 +182,51 @@ export class ExtensionsResourceExportTreeItem implements IProfileResourceTreeIte } async hasContent(): Promise { - const extensions = await this.instantiationService.createInstance(ExtensionsResource).getLocalExtensions(this.profile); + const extensions = await this.getExtensions(); return extensions.length > 0; } + abstract getContent(): Promise; + protected abstract getExtensions(): Promise; + +} + +export class ExtensionsResourceExportTreeItem extends ExtensionsResourceTreeItem { + + constructor( + private readonly profile: IUserDataProfile, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + } + + protected getExtensions(): Promise { + return this.instantiationService.createInstance(ExtensionsResource).getLocalExtensions(this.profile); + } + async getContent(): Promise { return this.instantiationService.createInstance(ExtensionsResource).getContent(this.profile, [...this.excludedExtensions.values()]); } } -export class ExtensionsResourceImportTreeItem implements IProfileResourceTreeItem { - - readonly handle = 'extensions'; - readonly label = { label: localize('extensions', "Extensions") }; - readonly collapsibleState = TreeItemCollapsibleState.Expanded; +export class ExtensionsResourceImportTreeItem extends ExtensionsResourceTreeItem { constructor( private readonly content: string, @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { } - - async getChildren(): Promise { - const extensions = await this.instantiationService.createInstance(ExtensionsResource).getProfileExtensions(this.content); - return extensions.map(e => ({ - handle: e.identifier.id.toLowerCase(), - parent: this, - label: { label: e.displayName || e.identifier.id }, - description: e.disabled ? localize('disabled', "Disabled") : undefined, - collapsibleState: TreeItemCollapsibleState.None, - command: { - id: 'extension.open', - title: '', - arguments: [e.identifier.id, undefined, true] - } - })); + ) { + super(); } - async hasContent(): Promise { - const extensions = await this.instantiationService.createInstance(ExtensionsResource).getProfileExtensions(this.content); - return extensions.length > 0; + protected getExtensions(): Promise { + return this.instantiationService.createInstance(ExtensionsResource).getProfileExtensions(this.content); + } + + async getContent(): Promise { + const extensionsResource = this.instantiationService.createInstance(ExtensionsResource); + const extensions = await extensionsResource.getProfileExtensions(this.content); + return extensionsResource.toContent(extensions, [...this.excludedExtensions.values()]); } } - - diff --git a/src/vs/workbench/services/userDataProfile/browser/globalStateResource.ts b/src/vs/workbench/services/userDataProfile/browser/globalStateResource.ts index 830a97c7831..60fc49468fb 100644 --- a/src/vs/workbench/services/userDataProfile/browser/globalStateResource.ts +++ b/src/vs/workbench/services/userDataProfile/browser/globalStateResource.ts @@ -13,7 +13,7 @@ import { IUserDataProfile } from 'vs/platform/userDataProfile/common/userDataPro import { IUserDataProfileStorageService } from 'vs/platform/userDataProfile/common/userDataProfileStorageService'; import { API_OPEN_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; import { ITreeItemCheckboxState, TreeItemCollapsibleState } from 'vs/workbench/common/views'; -import { IProfileResource, IProfileResourceTreeItem } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { IProfileResource, IProfileResourceTreeItem, ProfileResourceType } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; interface IGlobalState { storage: IStringDictionary; @@ -71,9 +71,10 @@ export class GlobalStateResource implements IProfileResource { } } -export class GlobalStateResourceExportTreeItem implements IProfileResourceTreeItem { +abstract class GlobalStateResourceTreeItem implements IProfileResourceTreeItem { - readonly handle = this.profile.globalStorageHome.toString(); + readonly type = ProfileResourceType.GlobalState; + readonly handle = 'globalState'; readonly label = { label: localize('globalState', "UI State") }; readonly collapsibleState = TreeItemCollapsibleState.None; checkbox: ITreeItemCheckboxState = { isChecked: true }; @@ -83,14 +84,23 @@ export class GlobalStateResourceExportTreeItem implements IProfileResourceTreeIt arguments: [this.resource, undefined, undefined] }; - constructor( - private readonly profile: IUserDataProfile, - private readonly resource: URI, - @IInstantiationService private readonly instantiationService: IInstantiationService - ) { } + constructor(private readonly resource: URI) { } async getChildren(): Promise { return undefined; } + abstract getContent(): Promise; +} + +export class GlobalStateResourceExportTreeItem extends GlobalStateResourceTreeItem { + + constructor( + private readonly profile: IUserDataProfile, + resource: URI, + @IInstantiationService private readonly instantiationService: IInstantiationService + ) { + super(resource); + } + async hasContent(): Promise { const globalState = await this.instantiationService.createInstance(GlobalStateResource).getGlobalState(this.profile); return Object.keys(globalState.storage).length > 0; @@ -102,21 +112,17 @@ export class GlobalStateResourceExportTreeItem implements IProfileResourceTreeIt } -export class GlobalStateResourceImportTreeItem implements IProfileResourceTreeItem { +export class GlobalStateResourceImportTreeItem extends GlobalStateResourceTreeItem { - readonly handle = 'globalState'; - readonly label = { label: localize('globalState', "UI State") }; - readonly collapsibleState = TreeItemCollapsibleState.None; - readonly command = { - id: API_OPEN_EDITOR_COMMAND_ID, - title: '', - arguments: [this.resource, undefined, undefined] - }; + constructor( + private readonly content: string, + resource: URI, + ) { + super(resource); + } - constructor(private readonly resource: URI) { } + async getContent(): Promise { + return this.content; + } - async getChildren(): Promise { return undefined; } } - - - diff --git a/src/vs/workbench/services/userDataProfile/browser/keybindingsResource.ts b/src/vs/workbench/services/userDataProfile/browser/keybindingsResource.ts index a2707695411..336bb9e426b 100644 --- a/src/vs/workbench/services/userDataProfile/browser/keybindingsResource.ts +++ b/src/vs/workbench/services/userDataProfile/browser/keybindingsResource.ts @@ -6,7 +6,7 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { FileOperationError, FileOperationResult, IFileService } from 'vs/platform/files/common/files'; import { ILogService } from 'vs/platform/log/common/log'; -import { IProfileResource, IProfileResourceTreeItem } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { IProfileResource, IProfileResourceTreeItem, ProfileResourceType } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; import { platform, Platform } from 'vs/base/common/platform'; import { ITreeItemCheckboxState, TreeItemCollapsibleState } from 'vs/workbench/common/views'; import { IUserDataProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; @@ -64,10 +64,11 @@ export class KeybindingsResource implements IProfileResource { export class KeybindingsResourceTreeItem implements IProfileResourceTreeItem { + readonly type = ProfileResourceType.Keybindings; readonly handle = this.profile.keybindingsResource.toString(); readonly label = { label: localize('keybindings', "Keyboard Shortcuts") }; readonly collapsibleState = TreeItemCollapsibleState.None; - checkbox: ITreeItemCheckboxState | undefined = { isChecked: true }; + checkbox: ITreeItemCheckboxState = { isChecked: true }; readonly command = { id: API_OPEN_EDITOR_COMMAND_ID, title: '', diff --git a/src/vs/workbench/services/userDataProfile/browser/settingsResource.ts b/src/vs/workbench/services/userDataProfile/browser/settingsResource.ts index cdb12f8f329..c2f67289423 100644 --- a/src/vs/workbench/services/userDataProfile/browser/settingsResource.ts +++ b/src/vs/workbench/services/userDataProfile/browser/settingsResource.ts @@ -8,7 +8,7 @@ import { ConfigurationScope, Extensions, IConfigurationRegistry } from 'vs/platf import { FileOperationError, FileOperationResult, IFileService } from 'vs/platform/files/common/files'; import { ILogService } from 'vs/platform/log/common/log'; import { Registry } from 'vs/platform/registry/common/platform'; -import { IProfileResource, IProfileResourceTreeItem } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { IProfileResource, IProfileResourceTreeItem, ProfileResourceType } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; import { updateIgnoredSettings } from 'vs/platform/userDataSync/common/settingsMerge'; import { IUserDataSyncUtilService } from 'vs/platform/userDataSync/common/userDataSync'; import { ITreeItemCheckboxState, TreeItemCollapsibleState } from 'vs/workbench/common/views'; @@ -83,10 +83,11 @@ export class SettingsResource implements IProfileResource { export class SettingsResourceTreeItem implements IProfileResourceTreeItem { + readonly type = ProfileResourceType.Settings; readonly handle = this.profile.settingsResource.toString(); readonly label = { label: localize('settings', "Settings") }; readonly collapsibleState = TreeItemCollapsibleState.None; - checkbox: ITreeItemCheckboxState | undefined = { isChecked: true }; + checkbox: ITreeItemCheckboxState = { isChecked: true }; readonly command = { id: API_OPEN_EDITOR_COMMAND_ID, title: '', diff --git a/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts b/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts index 2cca57fa599..b330cbcf8b3 100644 --- a/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts +++ b/src/vs/workbench/services/userDataProfile/browser/snippetsResource.ts @@ -14,7 +14,7 @@ import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity' import { IUserDataProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; import { API_OPEN_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; import { ITreeItemCheckboxState, TreeItemCollapsibleState } from 'vs/workbench/common/views'; -import { IProfileResource, IProfileResourceChildTreeItem, IProfileResourceTreeItem } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { IProfileResource, IProfileResourceChildTreeItem, IProfileResourceTreeItem, ProfileResourceType } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; interface ISnippetsContent { snippets: IStringDictionary; @@ -80,10 +80,11 @@ export class SnippetsResource implements IProfileResource { export class SnippetsResourceTreeItem implements IProfileResourceTreeItem { + readonly type = ProfileResourceType.Snippets; readonly handle = this.profile.snippetsHome.toString(); readonly label = { label: localize('snippets', "Snippets") }; readonly collapsibleState = TreeItemCollapsibleState.Collapsed; - checkbox: ITreeItemCheckboxState | undefined = { isChecked: true }; + checkbox: ITreeItemCheckboxState = { isChecked: true }; private readonly excludedSnippets = new ResourceSet(); diff --git a/src/vs/workbench/services/userDataProfile/browser/tasksResource.ts b/src/vs/workbench/services/userDataProfile/browser/tasksResource.ts index 3929f989fc8..7b27c485c19 100644 --- a/src/vs/workbench/services/userDataProfile/browser/tasksResource.ts +++ b/src/vs/workbench/services/userDataProfile/browser/tasksResource.ts @@ -11,7 +11,7 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IUserDataProfile } from 'vs/platform/userDataProfile/common/userDataProfile'; import { API_OPEN_EDITOR_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; import { ITreeItemCheckboxState, TreeItemCollapsibleState } from 'vs/workbench/common/views'; -import { IProfileResource, IProfileResourceTreeItem } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { IProfileResource, IProfileResourceTreeItem, ProfileResourceType } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; interface ITasksResourceContent { tasks: string | null; @@ -62,10 +62,11 @@ export class TasksResource implements IProfileResource { export class TasksResourceTreeItem implements IProfileResourceTreeItem { + readonly type = ProfileResourceType.Tasks; readonly handle = this.profile.tasksResource.toString(); readonly label = { label: localize('tasks', "User Tasks") }; readonly collapsibleState = TreeItemCollapsibleState.None; - checkbox: ITreeItemCheckboxState | undefined = { isChecked: true }; + checkbox: ITreeItemCheckboxState = { isChecked: true }; readonly command = { id: API_OPEN_EDITOR_COMMAND_ID, title: '', diff --git a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts index 2c54edce986..a4d51de898a 100644 --- a/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts +++ b/src/vs/workbench/services/userDataProfile/browser/userDataProfileImportExportService.ts @@ -8,7 +8,7 @@ import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/ import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { INotificationService } from 'vs/platform/notification/common/notification'; import * as DOM from 'vs/base/browser/dom'; -import { IUserDataProfileImportExportService, PROFILE_FILTER, PROFILE_EXTENSION, IUserDataProfileContentHandler, IS_PROFILE_IMPORT_EXPORT_IN_PROGRESS_CONTEXT, PROFILES_TTILE, defaultUserDataProfileIcon, IUserDataProfileService, IProfileResourceTreeItem, IProfileResourceChildTreeItem, PROFILES_CATEGORY, isUserDataProfileTemplate, IUserDataProfileManagementService } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; +import { IUserDataProfileImportExportService, PROFILE_FILTER, PROFILE_EXTENSION, IUserDataProfileContentHandler, IS_PROFILE_IMPORT_EXPORT_IN_PROGRESS_CONTEXT, PROFILES_TTILE, defaultUserDataProfileIcon, IUserDataProfileService, IProfileResourceTreeItem, IProfileResourceChildTreeItem, PROFILES_CATEGORY, isUserDataProfileTemplate, IUserDataProfileManagementService, ProfileResourceType } from 'vs/workbench/services/userDataProfile/common/userDataProfile'; import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; @@ -49,6 +49,13 @@ import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { VSBuffer } from 'vs/base/common/buffer'; import { joinPath } from 'vs/base/common/resources'; import { escapeRegExpCharacters } from 'vs/base/common/strings'; +import { Schemas } from 'vs/base/common/network'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import Severity from 'vs/base/common/severity'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; +import { IURLHandler, IURLService } from 'vs/platform/url/common/url'; +import { asText, IRequestService } from 'vs/platform/request/common/request'; +import { IProductService } from 'vs/platform/product/common/productService'; interface IUserDataProfileTemplate { readonly name: string; @@ -61,7 +68,9 @@ interface IUserDataProfileTemplate { readonly extensions?: string; } -export class UserDataProfileImportExportService extends Disposable implements IUserDataProfileImportExportService { +export class UserDataProfileImportExportService extends Disposable implements IUserDataProfileImportExportService, IURLHandler { + + private static readonly PROFILE_URL_AUTHORITY_PREFIX = 'profile-'; readonly _serviceBrand: undefined; @@ -85,10 +94,15 @@ export class UserDataProfileImportExportService extends Disposable implements IU @INotificationService private readonly notificationService: INotificationService, @IProgressService private readonly progressService: IProgressService, @IDialogService private readonly dialogService: IDialogService, + @IClipboardService private readonly clipboardService: IClipboardService, + @IOpenerService private readonly openerService: IOpenerService, + @IRequestService private readonly requestService: IRequestService, + @IURLService urlService: IURLService, + @IProductService private readonly productService: IProductService, @ILogService private readonly logService: ILogService, ) { super(); - this.registerProfileContentHandler(this.fileUserDataProfileContentHandler = instantiationService.createInstance(FileUserDataProfileContentHandler)); + this.registerProfileContentHandler(Schemas.file, this.fileUserDataProfileContentHandler = instantiationService.createInstance(FileUserDataProfileContentHandler)); this.isProfileImportExportInProgressContextKey = IS_PROFILE_IMPORT_EXPORT_IN_PROGRESS_CONTEXT.bindTo(contextKeyService); this.viewContainer = Registry.as(Extensions.ViewContainersRegistry).registerViewContainer( @@ -102,13 +116,31 @@ export class UserDataProfileImportExportService extends Disposable implements IU icon: defaultUserDataProfileIcon, hideIfEmpty: true, }, ViewContainerLocation.Sidebar); + + urlService.registerHandler(this); } - registerProfileContentHandler(profileContentHandler: IUserDataProfileContentHandler): void { - if (this.profileContentHandlers.has(profileContentHandler.id)) { - throw new Error(`Profile content handler with id '${profileContentHandler.id}' already registered.`); + private isProfileURL(uri: URI): boolean { + return new RegExp(`^${UserDataProfileImportExportService.PROFILE_URL_AUTHORITY_PREFIX}`).test(uri.authority); + } + + async handleURL(uri: URI): Promise { + if (this.isProfileURL(uri)) { + await this.importProfile(uri); + return true; } - this.profileContentHandlers.set(profileContentHandler.id, profileContentHandler); + return false; + } + + registerProfileContentHandler(id: string, profileContentHandler: IUserDataProfileContentHandler): void { + if (this.profileContentHandlers.has(id)) { + throw new Error(`Profile content handler with id '${id}' already registered.`); + } + this.profileContentHandlers.set(id, profileContentHandler); + } + + unregisterProfileContentHandler(id: string): void { + this.profileContentHandlers.delete(id); } async exportProfile(): Promise { @@ -122,16 +154,54 @@ export class UserDataProfileImportExportService extends Disposable implements IU try { disposables.add(toDisposable(() => this.isProfileImportExportInProgressContextKey.set(false))); - const userDataProfilesData = disposables.add(this.instantiationService.createInstance(UserDataProfileExportData, this.userDataProfileService.currentProfile)); - const exportProfile = await this.showProfilePreviewView(`workbench.views.profiles.export.preview`, localize('export profile preview', "Export"), userDataProfilesData); + const userDataProfilesExportState = disposables.add(this.instantiationService.createInstance(UserDataProfileExportState, this.userDataProfileService.currentProfile)); + + const title = localize('export profile preview', "Export"); + let exportProfile = await this.selectProfileResources( + userDataProfilesExportState, + localize('export title', "{0}: {1} ({2})", PROFILES_CATEGORY.value, title, this.userDataProfileService.currentProfile.name), + localize('export description', "Select data to export") + ); + + if (exportProfile === undefined) { + return; + } + + if (!exportProfile) { + exportProfile = await this.showProfilePreviewView(`workbench.views.profiles.export.preview`, title, userDataProfilesExportState); + } + + if (!exportProfile) { + return; + } + if (exportProfile) { - const profile = await userDataProfilesData.getProfileToExport(); + const profile = await userDataProfilesExportState.getProfileToExport(); if (!profile) { return; } - const resource = await this.saveProfileContent(profile.name, JSON.stringify(profile)); - if (resource) { - this.notificationService.info(localize('export success', "{0}: Exported successfully.", PROFILES_CATEGORY.value)); + const saveResult = await this.saveProfileContent(profile.name, JSON.stringify(profile)); + if (saveResult) { + const buttons = saveResult.id === Schemas.file ? undefined : [localize('copy', "Copy Link"), localize('open', "Open in {0}", this.profileContentHandlers.get(saveResult.id)?.name)]; + const result = await this.dialogService.show( + Severity.Info, + localize('export success', "Profile '{0}' is exported successfully.", profile.name), + buttons + ); + switch (result.choice) { + case 0: + await this.clipboardService.writeText( + URI.from({ + scheme: this.productService.urlProtocol, + authority: `${UserDataProfileImportExportService.PROFILE_URL_AUTHORITY_PREFIX}${saveResult.id}`, + path: `/${saveResult.resource.toString()}` + }).toString()); + break; + case 1: + await this.openerService.open(saveResult.resource.toString()); + break; + + } } } } finally { @@ -141,7 +211,7 @@ export class UserDataProfileImportExportService extends Disposable implements IU async importProfile(uri: URI): Promise { if (this.isProfileImportExportInProgressContextKey.get()) { - this.logService.warn('Profile import/export already in progress.'); + this.notificationService.warn('Profile import/export already in progress.'); return; } @@ -154,16 +224,34 @@ export class UserDataProfileImportExportService extends Disposable implements IU if (profileContent === null) { return; } - const profileTemplate: IUserDataProfileTemplate = JSON.parse(profileContent); + + let profileTemplate: IUserDataProfileTemplate = JSON.parse(profileContent); if (!isUserDataProfileTemplate(profileTemplate)) { this.notificationService.error('Invalid profile content.'); return; } - const userDataProfilesData = disposables.add(this.instantiationService.createInstance(UserDataProfileImportData, profileTemplate)); - const importProfile = await this.showProfilePreviewView(`workbench.views.profiles.import.preview`, localize('import profile preview', "Import"), userDataProfilesData); + const userDataProfileImportState = disposables.add(this.instantiationService.createInstance(UserDataProfileImportState, profileTemplate)); + + const title = localize('import profile preview', "Import"); + let importProfile = await this.selectProfileResources( + userDataProfileImportState, + localize('import title', "{0}: {1} ({2})", PROFILES_CATEGORY.value, title, profileTemplate.name), + localize('import description', "Select data to import") + ); + + if (importProfile === undefined) { + return; + } + + if (!importProfile) { + importProfile = await this.showProfilePreviewView(`workbench.views.profiles.import.preview`, title, userDataProfileImportState); + } + if (!importProfile) { return; } + + profileTemplate = await userDataProfileImportState.getProfileTemplateToImport(); const profile = await this.getProfileToImport(profileTemplate); if (!profile) { return; @@ -199,44 +287,77 @@ export class UserDataProfileImportExportService extends Disposable implements IU } } - private async saveProfileContent(name: string, content: string): Promise { - const profileContentHandler = await this.pickProfileContentHandler(); + private async saveProfileContent(name: string, content: string): Promise<{ resource: URI; id: string } | null> { + const id = await this.pickProfileContentHandler(); + if (!id) { + return null; + } + const profileContentHandler = this.profileContentHandlers.get(id); if (!profileContentHandler) { return null; } - const resource = await profileContentHandler.saveProfile(name, content); - return resource; + const resource = await profileContentHandler.saveProfile(name, content, CancellationToken.None); + return resource ? { resource, id } : null; } private async resolveProfileContent(resource: URI): Promise { if (await this.fileService.canHandleResource(resource)) { - return this.fileUserDataProfileContentHandler.readProfile(resource); + return this.fileUserDataProfileContentHandler.readProfile(resource, CancellationToken.None); } - await this.extensionService.activateByEvent(`onProfile:import:${resource.authority}`); - const profileContentHandler = this.profileContentHandlers.get(resource.authority); - return profileContentHandler?.readProfile(resource) ?? null; + + if (this.isProfileURL(resource)) { + const handlerId = resource.authority.substring(UserDataProfileImportExportService.PROFILE_URL_AUTHORITY_PREFIX.length); + await this.extensionService.activateByEvent(`onProfile:${handlerId}`); + const profileContentHandler = this.profileContentHandlers.get(handlerId); + if (profileContentHandler) { + return profileContentHandler.readProfile(URI.parse(resource.path.substring(1)), CancellationToken.None); + } + } + + await this.extensionService.activateByEvent('onProfile'); + for (const profileContentHandler of this.profileContentHandlers.values()) { + const content = await profileContentHandler.readProfile(resource, CancellationToken.None); + if (content !== null) { + return content; + } + } + + const context = await this.requestService.request({ type: 'GET', url: resource.toString(true) }, CancellationToken.None); + if (context.res.statusCode === 200) { + return await asText(context); + } else { + const message = await asText(context); + this.logService.info(`Failed to get profile from URL: ${resource.toString()}. Status code: ${context.res.statusCode}. Message: ${message}`); + } + + return null; } - private async pickProfileContentHandler(): Promise { + private async pickProfileContentHandler(): Promise { + await this.extensionService.activateByEvent('onProfile'); if (this.profileContentHandlers.size === 1) { return this.profileContentHandlers.values().next().value; } - await this.extensionService.activateByEvent('onProfile:export'); - return undefined; + const result = await this.quickInputService.pick([...this.profileContentHandlers.entries()].map(([id, handler]) => ({ label: handler.name, id })), + { placeHolder: localize('select profile content handler', "Select the location where to export the profile") }); + return result?.id; } private async getProfileToImport(profileTemplate: IUserDataProfileTemplate): Promise { const profile = this.userDataProfilesService.profiles.find(p => p.name === profileTemplate.name); if (profile) { - const confirmation = await this.dialogService.confirm({ - type: 'info', - message: localize('profile already exists', "Profile with name '{0}' already exists. Do you want to overwrite it?", profileTemplate.name), - primaryButton: localize('overwrite', "Overwrite"), - secondaryButton: localize('create new', "Create New Profile"), - }); - if (confirmation.confirmed) { - return profile; + const result = await this.dialogService.show( + Severity.Info, + localize('profile already exists', "Profile with name '{0}' already exists. Do you want to overwrite it?", profileTemplate.name), + [localize('overwrite', "Overwrite"), localize('create new', "Create New Profile"), localize('cancel', "Cancel")], + { cancelId: 2 } + ); + switch (result.choice) { + case 0: return profile; + case 2: return undefined; } + + // Create new profile const nameRegEx = new RegExp(`${escapeRegExpCharacters(profileTemplate.name)}\\s(\\d+)`); let nameIndex = 0; for (const profile of this.userDataProfilesService.profiles) { @@ -264,7 +385,64 @@ export class UserDataProfileImportExportService extends Disposable implements IU } } - private async showProfilePreviewView(id: string, name: string, userDataProfilesData: UserDataProfileTreeViewData): Promise { + private async selectProfileResources(profileImportExportState: UserDataProfileImportExportState, title: string, description: string): Promise { + type ProfileResourceQuickItem = { item: IProfileResourceTreeItem; label: string }; + const disposables: DisposableStore = new DisposableStore(); + const quickPick = this.quickInputService.createQuickPick(); + disposables.add(quickPick); + quickPick.title = title; + quickPick.ok = 'default'; + quickPick.customButton = true; + quickPick.customLabel = localize('show contents', "Show Contents"); + quickPick.description = description; + quickPick.canSelectMany = true; + quickPick.ignoreFocusOut = true; + quickPick.hideInput = true; + quickPick.hideCheckAll = true; + quickPick.busy = true; + + let accepted: boolean = false; + let preview: boolean = false; + disposables.add(quickPick.onDidAccept(() => { + accepted = true; + quickPick.hide(); + })); + disposables.add(quickPick.onDidCustom(() => { + preview = true; + quickPick.hide(); + })); + + const promise = new Promise((c, e) => { + disposables.add(quickPick.onDidHide(() => { + try { + if (accepted || preview) { + for (const root of roots) { + root.checkbox.isChecked = quickPick.selectedItems.some(({ item }) => item === root); + } + c(accepted); + } else { + c(undefined); + } + } catch (error) { + e(error); + } finally { + disposables.dispose(); + } + })); + }); + quickPick.show(); + + const roots = await profileImportExportState.getRoots(); + quickPick.busy = false; + + const items = roots.map(item => ({ item, label: item.label?.label ?? item.type })); + quickPick.items = items; + quickPick.selectedItems = items.filter(({ item }) => item.checkbox?.isChecked); + + return promise; + } + + private async showProfilePreviewView(id: string, name: string, userDataProfilesData: UserDataProfileImportExportState): Promise { const disposables = new DisposableStore(); const viewsRegistry = Registry.as(Extensions.ViewsRegistry); const treeView = disposables.add(this.instantiationService.createInstance(TreeView, id, name)); @@ -324,8 +502,7 @@ export class UserDataProfileImportExportService extends Disposable implements IU class FileUserDataProfileContentHandler implements IUserDataProfileContentHandler { - readonly id = 'file'; - readonly name = localize('file', "File"); + readonly name = localize('file', "Local"); constructor( @IFileDialogService private readonly fileDialogService: IFileDialogService, @@ -334,7 +511,7 @@ class FileUserDataProfileContentHandler implements IUserDataProfileContentHandle @ITextFileService private readonly textFileService: ITextFileService, ) { } - async saveProfile(name: string, content: string): Promise { + async saveProfile(name: string, content: string, token: CancellationToken): Promise { const profileLocation = await this.fileDialogService.showSaveDialog({ title: localize('export profile dialog', "Save Profile"), filters: PROFILE_FILTER, @@ -347,8 +524,11 @@ class FileUserDataProfileContentHandler implements IUserDataProfileContentHandle return profileLocation; } - async readProfile(uri: URI): Promise { - return (await this.fileService.readFile(uri)).value.toString(); + async readProfile(uri: URI, token: CancellationToken): Promise { + if (await this.fileService.canHandleResource(uri)) { + return (await this.fileService.readFile(uri, undefined, token)).value.toString(); + } + return null; } async selectProfile(): Promise { @@ -374,7 +554,7 @@ class UserDataProfileExportViewPane extends TreeViewPane { private totalTreeItemsCount: number = 0; constructor( - private readonly userDataProfileData: UserDataProfileTreeViewData, + private readonly userDataProfileData: UserDataProfileImportExportState, private readonly confirmLabel: string, private readonly onConfirm: () => void, private readonly onCancel: () => void, @@ -450,43 +630,10 @@ class UserDataProfileExportViewPane extends TreeViewPane { const USER_DATA_PROFILE_IMPORT_EXPORT_SCHEME = 'userdataprofileimportexport'; const USER_DATA_PROFILE_IMPORT_EXPORT_PREVIEW_SCHEME = 'userdataprofileimportexportpreview'; -abstract class UserDataProfileTreeViewData extends Disposable implements ITreeViewDataProvider { - - async getChildren(element?: ITreeItem): Promise { - if (element) { - return (element).getChildren(); - } else { - this.rootsPromise = undefined; - return this.getRoots(); - } - } - - private roots: IProfileResourceTreeItem[] = []; - private rootsPromise: Promise | undefined; - getRoots(): Promise { - if (!this.rootsPromise) { - this.rootsPromise = this.fetchRoots().then(roots => this.roots = roots); - } - return this.rootsPromise; - } - - isEnabled(): boolean { - return this.roots.some(root => root.checkbox?.isChecked ?? true); - } - - abstract onDidChangeCheckboxState(items: ITreeItem[]): ITreeItem[]; - protected abstract fetchRoots(): Promise; -} - -class UserDataProfileExportData extends UserDataProfileTreeViewData implements ITreeViewDataProvider { - - private readonly disposables = this._register(new DisposableStore()); +abstract class UserDataProfileImportExportState extends Disposable implements ITreeViewDataProvider { constructor( - private readonly profile: IUserDataProfile, - @IQuickInputService private readonly quickInputService: IQuickInputService, - @IFileService private readonly fileService: IFileService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IQuickInputService protected readonly quickInputService: IQuickInputService, ) { super(); } @@ -512,6 +659,89 @@ class UserDataProfileExportData extends UserDataProfileTreeViewData implements I return items; } + async getChildren(element?: ITreeItem): Promise { + if (element) { + return (element).getChildren(); + } else { + this.rootsPromise = undefined; + return this.getRoots(); + } + } + + private roots: IProfileResourceTreeItem[] = []; + private rootsPromise: Promise | undefined; + getRoots(): Promise { + if (!this.rootsPromise) { + this.rootsPromise = (async () => { + this.roots = await this.fetchRoots(); + return this.roots; + })(); + } + return this.rootsPromise; + } + + isEnabled(resourceType?: ProfileResourceType): boolean { + if (resourceType !== undefined) { + return this.roots.some(root => root.type === resourceType && root.checkbox?.isChecked); + } + return this.roots.some(root => root.checkbox?.isChecked ?? true); + } + + protected async getProfileTemplate(name: string, shortName: string | undefined): Promise { + const roots = await this.getRoots(); + let settings: string | undefined; + let keybindings: string | undefined; + let tasks: string | undefined; + let snippets: string | undefined; + let extensions: string | undefined; + let globalState: string | undefined; + for (const root of roots) { + if (!root.checkbox?.isChecked) { + continue; + } + if (root instanceof SettingsResourceTreeItem) { + settings = await root.getContent(); + } else if (root instanceof KeybindingsResourceTreeItem) { + keybindings = await root.getContent(); + } else if (root instanceof TasksResourceTreeItem) { + tasks = await root.getContent(); + } else if (root instanceof SnippetsResourceTreeItem) { + snippets = await root.getContent(); + } else if (root instanceof ExtensionsResourceExportTreeItem) { + extensions = await root.getContent(); + } else if (root instanceof GlobalStateResourceExportTreeItem) { + globalState = await root.getContent(); + } + } + + return { + name, + shortName, + settings, + keybindings, + tasks, + snippets, + extensions, + globalState + }; + } + + protected abstract fetchRoots(): Promise; +} + +class UserDataProfileExportState extends UserDataProfileImportExportState { + + private readonly disposables = this._register(new DisposableStore()); + + constructor( + private readonly profile: IUserDataProfile, + @IQuickInputService quickInputService: IQuickInputService, + @IFileService private readonly fileService: IFileService, + @IInstantiationService private readonly instantiationService: IInstantiationService + ) { + super(quickInputService); + } + protected async fetchRoots(): Promise { this.disposables.clear(); this.disposables.add(this.fileService.registerProvider(USER_DATA_PROFILE_IMPORT_EXPORT_SCHEME, this._register(new InMemoryFileSystemProvider()))); @@ -600,60 +830,22 @@ class UserDataProfileExportData extends UserDataProfileTreeViewData implements I } } - const roots = await this.getRoots(); - let settings: string | undefined; - let keybindings: string | undefined; - let tasks: string | undefined; - let snippets: string | undefined; - let extensions: string | undefined; - let globalState: string | undefined; - for (const root of roots) { - if (!root.checkbox?.isChecked) { - continue; - } - if (root instanceof SettingsResourceTreeItem) { - settings = await root.getContent(); - } else if (root instanceof KeybindingsResourceTreeItem) { - keybindings = await root.getContent(); - } else if (root instanceof TasksResourceTreeItem) { - tasks = await root.getContent(); - } else if (root instanceof SnippetsResourceTreeItem) { - snippets = await root.getContent(); - } else if (root instanceof ExtensionsResourceExportTreeItem) { - extensions = await root.getContent(); - } else if (root instanceof GlobalStateResourceExportTreeItem) { - globalState = await root.getContent(); - } - } - - return { - name, - shortName: this.profile.shortName, - settings, - keybindings, - tasks, - snippets, - extensions, - globalState - }; + return super.getProfileTemplate(name, this.profile.shortName); } } -class UserDataProfileImportData extends UserDataProfileTreeViewData implements ITreeViewDataProvider { +class UserDataProfileImportState extends UserDataProfileImportExportState { private readonly disposables = this._register(new DisposableStore()); constructor( private readonly profile: IUserDataProfileTemplate, @IFileService private readonly fileService: IFileService, + @IQuickInputService quickInputService: IQuickInputService, @IInstantiationService private readonly instantiationService: IInstantiationService ) { - super(); - } - - onDidChangeCheckboxState(items: ITreeItem[]): ITreeItem[] { - return items; + super(quickInputService); } protected async fetchRoots(): Promise { @@ -668,7 +860,6 @@ class UserDataProfileImportData extends UserDataProfileTreeViewData implements I const settingsResource = this.instantiationService.createInstance(SettingsResource); await settingsResource.apply(this.profile.settings, importPreviewProfle); const settingsResourceTreeItem = this.instantiationService.createInstance(SettingsResourceTreeItem, importPreviewProfle); - settingsResourceTreeItem.checkbox = undefined; if (await settingsResourceTreeItem.hasContent()) { roots.push(settingsResourceTreeItem); } @@ -678,7 +869,6 @@ class UserDataProfileImportData extends UserDataProfileTreeViewData implements I const keybindingsResource = this.instantiationService.createInstance(KeybindingsResource); await keybindingsResource.apply(this.profile.keybindings, importPreviewProfle); const keybindingsResourceTreeItem = this.instantiationService.createInstance(KeybindingsResourceTreeItem, importPreviewProfle); - keybindingsResourceTreeItem.checkbox = undefined; if (await keybindingsResourceTreeItem.hasContent()) { roots.push(keybindingsResourceTreeItem); } @@ -688,7 +878,6 @@ class UserDataProfileImportData extends UserDataProfileTreeViewData implements I const tasksResource = this.instantiationService.createInstance(TasksResource); await tasksResource.apply(this.profile.tasks, importPreviewProfle); const tasksResourceTreeItem = this.instantiationService.createInstance(TasksResourceTreeItem, importPreviewProfle); - tasksResourceTreeItem.checkbox = undefined; if (await tasksResourceTreeItem.hasContent()) { roots.push(tasksResourceTreeItem); } @@ -698,7 +887,6 @@ class UserDataProfileImportData extends UserDataProfileTreeViewData implements I const snippetsResource = this.instantiationService.createInstance(SnippetsResource); await snippetsResource.apply(this.profile.snippets, importPreviewProfle); const snippetsResourceTreeItem = this.instantiationService.createInstance(SnippetsResourceTreeItem, importPreviewProfle); - snippetsResourceTreeItem.checkbox = undefined; if (await snippetsResourceTreeItem.hasContent()) { roots.push(snippetsResourceTreeItem); } @@ -709,7 +897,7 @@ class UserDataProfileImportData extends UserDataProfileTreeViewData implements I const content = VSBuffer.fromString(JSON.stringify(JSON.parse(this.profile.globalState), null, '\t')); if (content) { await this.fileService.writeFile(globalStateResource, content); - roots.push(this.instantiationService.createInstance(GlobalStateResourceImportTreeItem, globalStateResource)); + roots.push(this.instantiationService.createInstance(GlobalStateResourceImportTreeItem, this.profile.globalState, globalStateResource)); } } @@ -725,6 +913,10 @@ class UserDataProfileImportData extends UserDataProfileTreeViewData implements I return roots; } + async getProfileTemplateToImport(): Promise { + return this.getProfileTemplate(this.profile.name, this.profile.shortName); + } + } registerSingleton(IUserDataProfileImportExportService, UserDataProfileImportExportService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/userDataProfile/common/userDataProfile.ts b/src/vs/workbench/services/userDataProfile/common/userDataProfile.ts index 03f15eb280d..f575df0fb83 100644 --- a/src/vs/workbench/services/userDataProfile/common/userDataProfile.ts +++ b/src/vs/workbench/services/userDataProfile/common/userDataProfile.ts @@ -13,7 +13,8 @@ import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { URI } from 'vs/base/common/uri'; import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; import { Codicon } from 'vs/base/common/codicons'; -import { ITreeItem } from 'vs/workbench/common/views'; +import { ITreeItem, ITreeItemCheckboxState, ITreeItemLabel } from 'vs/workbench/common/views'; +import { CancellationToken } from 'vs/base/common/cancellation'; export interface DidChangeUserDataProfileEvent { readonly preserveData: boolean; @@ -68,20 +69,34 @@ export const IUserDataProfileImportExportService = createDecorator; importProfile(uri: URI): Promise; setProfile(profile: IUserDataProfileTemplate): Promise; } +export const enum ProfileResourceType { + Settings = 'settings', + Keybindings = 'keybindings', + Snippets = 'snippets', + Tasks = 'tasks', + Extensions = 'extensions', + GlobalState = 'globalState', +} + export interface IProfileResource { getContent(profile: IUserDataProfile): Promise; apply(content: string, profile: IUserDataProfile): Promise; } export interface IProfileResourceTreeItem extends ITreeItem { + readonly type: ProfileResourceType; + checkbox: ITreeItemCheckboxState; + readonly label: ITreeItemLabel; getChildren(): Promise; + getContent(): Promise; } export interface IProfileResourceChildTreeItem extends ITreeItem { @@ -89,11 +104,11 @@ export interface IProfileResourceChildTreeItem extends ITreeItem { } export interface IUserDataProfileContentHandler { - readonly id: string; readonly name: string; readonly description?: string; - saveProfile(name: string, content: string): Promise; - readProfile(uri: URI): Promise; + readonly extensionId?: string; + saveProfile(name: string, content: string, token: CancellationToken): Promise; + readProfile(uri: URI, token: CancellationToken): Promise; } export const defaultUserDataProfileIcon = registerIcon('defaultProfile-icon', Codicon.settings, localize('defaultProfileIcon', 'Icon for Default Profile.')); diff --git a/src/vscode-dts/vscode.proposed.profileContentHandlers.d.ts b/src/vscode-dts/vscode.proposed.profileContentHandlers.d.ts new file mode 100644 index 00000000000..c89b7b0cff0 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.profileContentHandlers.d.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + export interface ProfileContentHandler { + readonly name: string; + readonly description?: string; + saveProfile(name: string, content: string, token: CancellationToken): Thenable; + readProfile(uri: Uri, token: CancellationToken): Thenable; + } + + export namespace window { + export function registerProfileContentHandler(id: string, profileContentHandler: ProfileContentHandler): Disposable; + } + +}