Skip to content

Commit

Permalink
First cut of allowing extensions to contribute getting started content.
Browse files Browse the repository at this point in the history
Closes #116414.
  • Loading branch information
Jackson Kearl committed Feb 17, 2021
1 parent a5ff0dd commit 4f12bb8
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 7 deletions.
10 changes: 10 additions & 0 deletions src/vs/platform/extensions/common/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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', {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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: []
}
}
];
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.")
}
}
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<IGettingStartedService>('gettingStartedService');

Expand Down Expand Up @@ -63,11 +67,14 @@ export class GettingStartedService extends Disposable implements IGettingStarted
private commandListeners = new Map<string, string[]>();
private eventListeners = new Map<string, string[]>();

private trackedExtensions = new Set<string>();

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();

Expand All @@ -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));
Expand All @@ -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);
Expand Down

0 comments on commit 4f12bb8

Please sign in to comment.