From 4f12bb8451fce31fbc2c25521dfa91bd00d9d081 Mon Sep 17 00:00:00 2001 From: Jackson Kearl Date: Wed, 17 Feb 2021 11:45:30 -0800 Subject: [PATCH] First cut of allowing extensions to contribute getting started content. Closes #116414. --- .../platform/extensions/common/extensions.ts | 10 ++ .../gettingStarted/browser/gettingStarted.ts | 10 +- .../common/gettingStartedContent.ts | 13 ++- .../common/gettingStartedRegistry.ts | 95 +++++++++++++++++++ .../common/gettingStartedService.ts | 68 ++++++++++++- 5 files changed, 189 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index 8f0a3b204c3ee..a3b24d7c4a7c4 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -113,6 +113,15 @@ export interface IAuthenticationContribution { readonly label: string; } +export interface IGettingStartedContent { + readonly id: string; + readonly title: string; + readonly description: string; + readonly button: { title: string } & ({ command?: never, link: string } | { command: string, link?: never }), + readonly media: { path: string | { hc: string, light: string, dark: string }, altText: string }, + readonly when?: string; +} + export interface IExtensionContributions { commands?: ICommand[]; configuration?: IConfiguration | IConfiguration[]; @@ -132,6 +141,7 @@ export interface IExtensionContributions { readonly customEditors?: readonly IWebviewEditor[]; readonly codeActions?: readonly ICodeActionContribution[]; authentication?: IAuthenticationContribution[]; + gettingStarted?: IGettingStartedContent[]; } export type ExtensionKind = 'ui' | 'workspace' | 'web'; diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts index f890c8f273e44..45ea20fbcaf1a 100644 --- a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts @@ -104,7 +104,11 @@ export class GettingStartedPage extends EditorPane { this.gettingStartedCategories = this.gettingStartedService.getCategories(); this._register(this.dispatchListeners); - this._register(this.gettingStartedService.onDidAddTask(task => console.log('added new task', task, 'that isnt being rendered yet'))); + this._register(this.gettingStartedService.onDidAddTask(task => { + this.gettingStartedCategories = this.gettingStartedService.getCategories(); + this.buildCategoriesSlide(); + })); + this._register(this.gettingStartedService.onDidAddCategory(category => console.log('added new category', category, 'that isnt being rendered yet'))); this._register(this.gettingStartedService.onDidProgressTask(task => { const category = this.gettingStartedCategories.find(category => category.id === task.category); @@ -307,9 +311,7 @@ export class GettingStartedPage extends EditorPane { $('.category-description.description', { 'aria-label': category.description + ' ' + localize('pressEnterToSelect', "Press Enter to Select") }, category.description), $('.category-progress', { 'x-data-category-id': category.id, }, $('.message'), - $('.progress-bar-outer', { - 'role': 'progressbar' - }, + $('.progress-bar-outer', { 'role': 'progressbar' }, $('.progress-bar-inner')))) : $('.category-description-container', {}, diff --git a/src/vs/workbench/services/gettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/services/gettingStarted/common/gettingStartedContent.ts index 64e10c2eb92ad..3e98f0e75c1c1 100644 --- a/src/vs/workbench/services/gettingStarted/common/gettingStartedContent.ts +++ b/src/vs/workbench/services/gettingStarted/common/gettingStartedContent.ts @@ -12,6 +12,7 @@ import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; const setupIcon = registerIcon('getting-started-setup', Codicon.heart, localize('getting-started-setup-icon', "Icon used for the setup category of getting started")); const beginnerIcon = registerIcon('getting-started-beginner', Codicon.lightbulb, localize('getting-started-beginner-icon', "Icon used for the beginner category of getting started")); const codespacesIcon = registerIcon('getting-started-codespaces', Codicon.github, localize('getting-started-codespaces-icon', "Icon used for the codespaces category of getting started")); +const extensionsIcon = registerIcon('getting-started-extensions', Codicon.extensions, localize('getting-started-extensions-icon', "Icon used for the extensions category of getting started")); type GettingStartedItem = { @@ -269,6 +270,16 @@ export const content: GettingStartedContent = [ } ] } - } + }, + { + id: 'ExtensionContrib', + title: localize('gettingStarted.extensionContrib.title', "Discover Your Extensions"), + icon: extensionsIcon, + description: localize('gettingStarted.extensionContrib.description', "Learn about features contributed by installed extensions."), + content: { + type: 'items', + items: [] + } + } ]; diff --git a/src/vs/workbench/services/gettingStarted/common/gettingStartedRegistry.ts b/src/vs/workbench/services/gettingStarted/common/gettingStartedRegistry.ts index c6f32091b3e29..f77f204417efb 100644 --- a/src/vs/workbench/services/gettingStarted/common/gettingStartedRegistry.ts +++ b/src/vs/workbench/services/gettingStarted/common/gettingStartedRegistry.ts @@ -7,9 +7,11 @@ import { Emitter, Event } from 'vs/base/common/event'; import { FileAccess } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; import { ContextKeyExpr, ContextKeyExpression } from 'vs/platform/contextkey/common/contextkey'; +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; import { Registry } from 'vs/platform/registry/common/platform'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; import { content } from 'vs/workbench/services/gettingStarted/common/gettingStartedContent'; +import { localize } from 'vs/nls'; export const enum GettingStartedCategory { Beginner = 'Beginner', @@ -165,3 +167,96 @@ content.forEach(category => { Registry.add(GettingStartedRegistryID, registryImpl); export const GettingStartedRegistry: IGettingStartedRegistry = Registry.as(GettingStartedRegistryID); + +ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'gettingStarted', + jsonSchema: { + doNotSuggest: true, + description: localize('gettingStarted', "Contribute items to help users in getting started with your extension. Rendering and progression through these items is managed by core. Experimental, requires proposedApi."), + type: 'array', + items: { + type: 'object', + required: ['id', 'title', 'description', 'button', 'media'], + properties: { + id: { + type: 'string', + description: localize('gettingStarted.id', "Unique identifier for this item."), + }, + title: { + type: 'string', + description: localize('gettingStarted.title', "Title of item.") + }, + description: { + type: 'string', + description: localize('gettingStarted.description', "Description of item.") + }, + button: { + description: localize('gettingStarted.button', "The item's button, which can either link to an external resource or run a command"), + oneOf: [ + { + type: 'object', + required: ['title', 'command'], + properties: { + title: { + type: 'string', + description: localize('gettingStarted.button.title', "Title of button.") + }, + command: { + type: 'string', + description: localize('gettingStarted.button.command', "Command to run when button is clicked. Running this command will mark the item completed.") + } + } + }, + { + type: 'object', + required: ['title', 'link'], + properties: { + title: { + type: 'string', + description: localize('gettingStarted.button.title', "Title of button.") + }, + link: { + type: 'string', + description: localize('gettingStarted.button.link', "Link to open when button is clicked. Opening this link will mark the item completed.") + } + } + } + ] + }, + media: { + type: 'object', + required: ['path', 'altText'], + description: localize('gettingStarted.media', "Image to show alongside this item."), + properties: { + path: { + description: localize('gettingStarted.media.path', "Either a single string path to an image to be used on all color themes, or separate paths for light, dark, and high contrast themes."), + + oneOf: [ + { + type: 'string', + }, + { + type: 'object', + required: ['hc', 'light', 'dark'], + properties: { + hc: { type: 'string' }, + light: { type: 'string' }, + dark: { type: 'string' }, + } + }, + ] + }, + altText: { + type: 'string', + description: localize('gettingStarted.media.altText', "Alternate text to display when the image cannot be loaded or in screen readers.") + } + } + }, + when: { + type: 'string', + description: localize('gettingStarted.when', "Context key expression to control the visibility of this getting started item.") + } + } + } + } +}); diff --git a/src/vs/workbench/services/gettingStarted/common/gettingStartedService.ts b/src/vs/workbench/services/gettingStarted/common/gettingStartedService.ts index b5784ee9ddb3a..d741ae8683a52 100644 --- a/src/vs/workbench/services/gettingStarted/common/gettingStartedService.ts +++ b/src/vs/workbench/services/gettingStarted/common/gettingStartedService.ts @@ -11,9 +11,13 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag import { Memento } from 'vs/workbench/common/memento'; import { Action2, registerAction2 } from 'vs/platform/actions/common/actions'; import { ICommandService } from 'vs/platform/commands/common/commands'; -import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { Disposable } from 'vs/base/common/lifecycle'; import { IUserDataAutoSyncEnablementService } from 'vs/platform/userDataSync/common/userDataSync'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { URI } from 'vs/base/common/uri'; +import { joinPath } from 'vs/base/common/resources'; export const IGettingStartedService = createDecorator('gettingStartedService'); @@ -63,11 +67,14 @@ export class GettingStartedService extends Disposable implements IGettingStarted private commandListeners = new Map(); private eventListeners = new Map(); + private trackedExtensions = new Set(); + constructor( @IStorageService private readonly storageService: IStorageService, @ICommandService private readonly commandService: ICommandService, @IContextKeyService private readonly contextService: IContextKeyService, @IUserDataAutoSyncEnablementService readonly userDataAutoSyncEnablementService: IUserDataAutoSyncEnablementService, + @IExtensionService private readonly extensionService: IExtensionService, ) { super(); @@ -80,7 +87,20 @@ export class GettingStartedService extends Disposable implements IGettingStarted } }); - this._register(this.registry.onDidAddCategory(category => this._onDidAddCategory.fire(this.getCategoryProgress(category)))); + this.extensionService.getExtensions().then(extensions => { + extensions.forEach(extension => this.registerExtensionContributions(extension)); + }); + + this.extensionService.onDidChangeExtensions(() => { + this.extensionService.getExtensions().then(extensions => { + extensions.forEach(extension => this.registerExtensionContributions(extension)); + }); + }); + + this._register(this.registry.onDidAddCategory(category => + this._onDidAddCategory.fire(this.getCategoryProgress(category)) + )); + this._register(this.registry.onDidAddTask(task => { this.registerDoneListeners(task); this._onDidAddTask.fire(this.getTaskProgress(task)); @@ -93,6 +113,50 @@ export class GettingStartedService extends Disposable implements IGettingStarted })); } + private registerExtensionContributions(extension: IExtensionDescription) { + const convertPaths = (path: string | { hc: string, dark: string, light: string }): { hc: URI, dark: URI, light: URI } => { + const convertPath = (path: string) => path.startsWith('https://') + ? URI.parse(path, true) + : joinPath(extension.extensionLocation, path); + + if (typeof path === 'string') { + const converted = convertPath(path); + return { hc: converted, dark: converted, light: converted }; + } else { + return { + hc: convertPath(path.hc), + light: convertPath(path.light), + dark: convertPath(path.dark) + }; + } + }; + + if (!this.trackedExtensions.has(ExtensionIdentifier.toKey(extension.identifier))) { + this.trackedExtensions.add(ExtensionIdentifier.toKey(extension.identifier)); + + if (extension.contributes?.gettingStarted?.length) { + if (!extension.enableProposedApi) { + console.warn('Extension', extension.identifier.value, 'contributes getting started content but has not enabled proposedApi. The contributed content will be disregarded.'); + return; + } + + extension.contributes?.gettingStarted.forEach((content, index) => { + this.registry.registerTask({ + button: content.button, + description: content.description, + media: { type: 'image', altText: content.media.altText, path: convertPaths(content.media.path) }, + doneOn: content.button.command ? { commandExecuted: content.button.command } : { eventFired: `linkOpened:${content.button.link}` }, + id: content.id, + title: content.title, + when: ContextKeyExpr.deserialize(content.when) ?? ContextKeyExpr.true(), + category: 'ExtensionContrib', + order: index, + }); + }); + } + } + } + private registerDoneListeners(task: IGettingStartedTask) { if (task.doneOn.commandExecuted) { const existing = this.commandListeners.get(task.doneOn.commandExecuted);