Improve getting started page

- Allow clicking checks to dismiss items
- Allow hiding categories
This commit is contained in:
Jackson Kearl 2021-03-12 21:09:13 -08:00
parent e05ab31f37
commit 84fe402d65
No known key found for this signature in database
GPG key ID: DA09A59C409FC400
6 changed files with 295 additions and 157 deletions

View file

@ -113,12 +113,22 @@ export interface IAuthenticationContribution {
readonly label: string;
}
export interface IGettingStartedContent {
export interface IWelcomeItem {
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 doneOn?:
| { event: string; command?: never }
| { event?: never; command: string };
readonly when?: string;
}
export interface IWelcomeCategory {
readonly id: string,
readonly title: string;
readonly description: string;
readonly when?: string;
}
@ -141,7 +151,8 @@ export interface IExtensionContributions {
readonly customEditors?: readonly IWebviewEditor[];
readonly codeActions?: readonly ICodeActionContribution[];
authentication?: IAuthenticationContribution[];
gettingStarted?: IGettingStartedContent[];
welcomeItems?: { [category: string]: IWelcomeItem[] };
welcomeCategories?: IWelcomeCategory[];
}
export type ExtensionKind = 'ui' | 'workspace' | 'web';

View file

@ -15,6 +15,8 @@ import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegis
import { KeyCode } from 'vs/base/common/keyCodes';
import { EditorDescriptor, IEditorRegistry, Extensions as EditorExtensions } from 'vs/workbench/browser/editor';
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry';
import { workbenchConfigurationNodeBase } from 'vs/workbench/common/configuration';
export * as icons from 'vs/workbench/contrib/welcome/gettingStarted/browser/gettingStartedIcons';
@ -128,3 +130,18 @@ registerAction2(class extends Action2 {
}
}
});
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration)
.registerConfiguration({
...workbenchConfigurationNodeBase,
'properties': {
'gettingStarted.hiddenCategories': {
'scope': ConfigurationScope.APPLICATION,
'type': 'array',
'items': { type: 'string' },
'default': [],
'description': localize('gettingStarted.hiddenCategories', "Hide categories of the welcome page's getting started section that are not relevant to you.")
},
}
});

View file

@ -18,6 +18,7 @@
width: 100%;
user-select: initial;
-webkit-user-select: initial;
outline: none;
}
.monaco-workbench .part.editor > .content .gettingStartedContainer img {
@ -183,6 +184,7 @@
.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide.categories .getting-started-category {
padding-right: 46px;
position: relative;
}
.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .codicon {
@ -191,8 +193,24 @@
font-size: 20px;
}
.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category img.category-icon {
.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .codicon.hide-category-button {
position: absolute;
right: -6px;
font-size: 12px;
padding-right: 0;
}
.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide.categories .getting-started-category img.category-icon {
margin-right: 10px;
margin-left: 10px;
padding-right: 8px;
max-width: 20px;
max-height: 20px;
}
.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide.detail .getting-started-category img.category-icon {
margin-right: 10px;
margin-left: 10px;
max-width: 32px;
max-height: 32px;
}

View file

@ -39,10 +39,11 @@ import { splitName } from 'vs/base/common/labels';
import { IHostService } from 'vs/workbench/services/host/browser/host';
import { coalesce } from 'vs/base/common/arrays';
import { isMacintosh } from 'vs/base/common/platform';
const SLIDE_TRANSITION_TIME_MS = 250;
const configurationKey = 'workbench.startupEditor';
const hiddenEntriesConfigurationKey = 'gettingStarted.hiddenCategories';
export const gettingStartedInputTypeId = 'workbench.editors.gettingStartedInput';
export const inGettingStartedContext = new RawContextKey('inGettingStarted', false);
@ -134,7 +135,13 @@ export class GettingStartedPage extends EditorPane {
super(GettingStartedPage.ID, telemetryService, themeService, storageService);
this.container = $('.gettingStartedContainer');
this.container = $('.gettingStartedContainer',
{
role: 'document',
tabindex: 0,
'aria-label': localize('gettingStartedLabel', "Getting Started. Overview of how to get up to speed with your editor.")
});
this.tasExperimentService = tasExperimentService;
this.contextService = this._register(contextService.createScoped(this.container));
@ -224,9 +231,20 @@ export class GettingStartedPage extends EditorPane {
}
break;
}
case 'hideCategory': {
const selectedCategory = this.gettingStartedCategories.find(category => category.id === argument);
if (!selectedCategory) { throw Error('Could not find category with ID ' + argument); }
this.configurationService.updateValue(hiddenEntriesConfigurationKey,
[...(this.configurationService.getValue<string[]>(hiddenEntriesConfigurationKey) ?? []), argument]);
element.parentElement?.remove();
break;
}
case 'selectTask': {
this.selectTask(argument);
e.stopPropagation();
break;
}
case 'completeTask': {
this.gettingStartedService.progressTask(argument);
break;
}
case 'runTaskAction': {
@ -242,7 +260,6 @@ export class GettingStartedPage extends EditorPane {
} else {
throw Error('Task ' + JSON.stringify(taskToRun) + ' does not have an associated action');
}
e.stopPropagation();
break;
}
default: {
@ -250,6 +267,7 @@ export class GettingStartedPage extends EditorPane {
break;
}
}
e.stopPropagation();
}));
}
});
@ -331,9 +349,7 @@ export class GettingStartedPage extends EditorPane {
);
const gettingStartedPage =
$('.gettingStarted.welcomePageFocusElement', {
role: 'document',
'aria-label': localize('gettingStartedLabel', "Getting Started. Overview of how to get up to speed with your editor.")
$('.gettingStarted', {
},
$('.gettingStartedSlideCategory.gettingStartedSlide.categories'),
tasksSlide
@ -350,30 +366,39 @@ export class GettingStartedPage extends EditorPane {
}
private async buildCategoriesSlide() {
const categoryElements = this.gettingStartedCategories.filter(entry => entry.content.type === 'items').map(
category => {
const categoryDescriptionElement =
category.content.type === 'items' ?
$('.category-description-container', {},
$('h3.category-title', {}, category.title),
// $('.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-inner'))))
:
$('.category-description-container', {},
$('h3.category-title', {}, category.title),
$('.category-description.description', { 'aria-label': category.description + ' ' + localize('pressEnterToSelect', "Press Enter to Select") }, category.description));
const hiddenCategories = new Set(this.configurationService.getValue(hiddenEntriesConfigurationKey) ?? []);
const categoryElements = this.gettingStartedCategories
.filter(entry => entry.content.type === 'items')
.filter(entry => !hiddenCategories.has(entry.id))
.map(
category => {
const categoryDescriptionElement =
category.content.type === 'items' ?
$('.category-description-container', {},
$('h3.category-title', {}, category.title),
// $('.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-inner'))))
:
$('.category-description-container', {},
$('h3.category-title', {}, category.title),
$('.category-description.description', { 'aria-label': category.description + ' ' + localize('pressEnterToSelect', "Press Enter to Select") }, category.description));
return $('button.getting-started-category',
{
'x-dispatch': 'selectCategory:' + category.id,
'role': 'listitem',
},
this.iconWidgetFor(category),
categoryDescriptionElement);
});
return $('button.getting-started-category',
{
'x-dispatch': 'selectCategory:' + category.id,
'role': 'listitem',
'title': category.description
},
this.iconWidgetFor(category),
$('a.codicon.codicon-close.hide-category-button', {
'x-dispatch': 'hideCategory:' + category.id,
'title': localize('close', "Hide"),
}),
categoryDescriptionElement);
});
const categoryScrollContainer = $('.getting-started-categories-scrolling-container');
const categoriesContainer = $('ul.getting-started-categories-container', { 'role': 'list' });
@ -542,8 +567,8 @@ export class GettingStartedPage extends EditorPane {
if (entry.content.type === 'items') { return undefined; }
const li = $('li', { 'x-dispatch': 'selectCategory:' + entry.id }, this.iconWidgetFor(entry));
const button = $<HTMLAnchorElement>('button.button-link');
const li = $('li', {}, this.iconWidgetFor(entry));
const button = $<HTMLAnchorElement>('button.button-link', { 'x-dispatch': 'selectCategory:' + entry.id });
button.innerText = entry.title;
button.title = entry.description;
@ -597,8 +622,8 @@ export class GettingStartedPage extends EditorPane {
bar.setAttribute('aria-valuemin', '0');
bar.setAttribute('aria-valuenow', '' + numDone);
bar.setAttribute('aria-valuemax', '' + numTotal);
bar.style.width = `${(numDone / numTotal) * 100}%`;
const progress = Math.max((numDone / numTotal) * 100, 3);
bar.style.width = `${progress}%`;
if (numTotal === numDone) {
bar.title = `All items complete!`;
@ -657,7 +682,11 @@ export class GettingStartedPage extends EditorPane {
'aria-checked': '' + task.done,
'role': 'listitem',
},
$('.codicon' + (task.done ? '.complete' + ThemeIcon.asCSSSelector(gettingStartedCheckedCodicon) : ThemeIcon.asCSSSelector(gettingStartedUncheckedCodicon)), { 'data-done-task-id': task.id }),
$('.codicon' + (task.done ? '.complete' + ThemeIcon.asCSSSelector(gettingStartedCheckedCodicon) : ThemeIcon.asCSSSelector(gettingStartedUncheckedCodicon)),
{
'data-done-task-id': task.id,
'x-dispatch': 'completeTask:' + task.id,
}),
$('.task-description-container', {},
$('h3.task-title', {}, task.title),
$('.task-description.description', {}, task.description),
@ -751,15 +780,6 @@ export class GettingStartedPage extends EditorPane {
}
}
private focusFirstUncompletedCategory() {
let toFocus!: HTMLElement;
this.container.querySelectorAll('.category-progress').forEach(progress => {
const progressAmount = assertIsDefined(progress.querySelector('.progress-bar-inner') as HTMLDivElement).style.width;
if (!toFocus && progressAmount !== '100%') { toFocus = assertIsDefined(progress.parentElement?.parentElement); }
});
(toFocus ?? assertIsDefined(this.container.querySelector('button.getting-started-category')) as HTMLButtonElement)?.focus();
}
private setSlide(toEnable: 'details' | 'categories') {
const slideManager = assertIsDefined(this.container.querySelector('.gettingStarted'));
if (toEnable === 'categories') {
@ -767,7 +787,7 @@ export class GettingStartedPage extends EditorPane {
slideManager.classList.add('showCategories');
this.container.querySelector('.gettingStartedSlideDetails')!.querySelectorAll('button').forEach(button => button.disabled = true);
this.container.querySelector('.gettingStartedSlideCategory')!.querySelectorAll('button').forEach(button => button.disabled = false);
this.focusFirstUncompletedCategory();
this.container.focus();
} else {
slideManager.classList.add('showDetails');
slideManager.classList.remove('showCategories');

View file

@ -174,97 +174,157 @@ Registry.add(GettingStartedRegistryID, registryImpl);
export const GettingStartedRegistry: IGettingStartedRegistry = Registry.as(GettingStartedRegistryID);
ExtensionsRegistry.registerExtensionPoint({
extensionPoint: 'gettingStarted',
extensionPoint: 'welcomeItems',
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'],
defaultSnippets: [{ body: { 'id': '$1', 'title': '$2', 'description': '$3', 'button': { 'title': '$4' }, 'media': { 'path': '$5', 'altText': '$6' } } }],
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'],
defaultSnippets: [{ 'body': { 'title': '$1', 'command': '$2' } }],
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'],
defaultSnippets: [{ 'body': { 'title': '$1', 'link': '$2' } }],
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."),
defaultSnippets: [{ 'body': { 'altText': '$1' } }],
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',
defaultSnippets: [{ 'body': '$1' }],
},
{
type: 'object',
defaultSnippets: [{ 'body': { 'hc': '$1', 'light': '$2', 'dark': '$3' } }],
required: ['hc', 'light', 'dark'],
properties: {
hc: { type: 'string' },
light: { type: 'string' },
dark: { type: 'string' },
description: localize('gettingStarted', "Contribute items to help users in getting started with your extension. Keys correspond to categories contributed via welcomeCategories contribution point. Experimental, available in VS Code Insiders only."),
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'object',
required: ['id', 'title', 'description', 'button', 'media'],
defaultSnippets: [{ body: { 'id': '$1', 'title': '$2', 'description': '$3', 'button': { 'title': '$4' }, 'media': { 'path': '$5', 'altText': '$6' } } }],
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'],
defaultSnippets: [{ 'body': { 'title': '$1', 'command': '$2' } }],
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.")
}
},
]
},
altText: {
type: 'string',
description: localize('gettingStarted.media.altText', "Alternate text to display when the image cannot be loaded or in screen readers.")
}
},
{
type: 'object',
required: ['title', 'link'],
defaultSnippets: [{ 'body': { 'title': '$1', 'link': '$2' } }],
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."),
defaultSnippets: [{ 'body': { 'altText': '$1' } }],
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',
defaultSnippets: [{ 'body': '$1' }],
},
{
type: 'object',
defaultSnippets: [{ 'body': { 'hc': '$1', 'light': '$2', 'dark': '$3' } }],
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.")
}
}
},
doneOn: {
oneOf: [
{
type: 'object',
required: ['event'],
properties: {
'event': {
description: localize('gettingStarted.oneOn.event', "Mark item done when the specified event is marked via the invoking the `welcomeItems.markEvent` command."),
type: 'string'
}
}
},
{
type: 'object',
required: ['command'],
properties: {
'command': {
description: localize('gettingStarted.oneOn.command', "Mark item done when the specified command is executed."),
type: 'string'
}
}
},
],
description: localize('gettingStarted.doneOn', "Signal to mark item as complete. If not defined, running the button's command will mark the item complete.")
},
when: {
type: 'string',
description: localize('gettingStarted.when', "Context key expression to control the visibility of this getting started item.")
}
},
when: {
type: 'string',
description: localize('gettingStarted.when', "Context key expression to control the visibility of this getting started item.")
}
}
}
}
});
ExtensionsRegistry.registerExtensionPoint({
extensionPoint: 'welcomeCategories',
jsonSchema: {
doNotSuggest: true,
description: localize('welcomeCategories', "Contribute categories of items to help users in getting started with your extension. Items themselves are contributed via welcomeItems contribution point. Experimental, available in VS Code Insiders only."),
type: 'array',
items: {
type: 'object',
required: ['id', 'title', 'description'],
defaultSnippets: [{ body: { 'id': '$1', 'title': '$2', 'description': '$3' } }],
properties: {
id: {
type: 'string',
description: localize('welcomeCategories.id', "Unique identifier for this category."),
},
title: {
type: 'string',
description: localize('welcomeCategories.title', "Title of category.")
},
description: {
type: 'string',
description: localize('welcomeCategories.description', "Description of category.")
},
when: {
type: 'string',
description: localize('welcomeCategories.when', "Context key expression to control the visibility of this category.")
}
}
}

View file

@ -19,8 +19,8 @@ import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensio
import { URI } from 'vs/base/common/uri';
import { joinPath } from 'vs/base/common/resources';
import { FileAccess } from 'vs/base/common/network';
import { localize } from 'vs/nls';
import { DefaultIconPath } from 'vs/platform/extensionManagement/common/extensionManagement';
import product from 'vs/platform/product/common/product';
export const IGettingStartedService = createDecorator<IGettingStartedService>('gettingStartedService');
@ -50,6 +50,7 @@ export interface IGettingStartedService {
getCategories(): IGettingStartedCategoryWithProgress[]
progressByEvent(eventName: string): void;
progressTask(id: string): void;
}
export class GettingStartedService extends Disposable implements IGettingStartedService {
@ -137,18 +138,20 @@ export class GettingStartedService extends Disposable implements IGettingStarted
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;
}
if ((extension.contributes?.welcomeCategories || extension.contributes?.welcomeItems) && product.quality === 'stable') {
console.warn('Extension', extension.identifier.value, 'contributes welcome page content but this is a Stable build and extension contributions are only available in Insiders. The contributed content will be disregarded.');
return;
}
const categoryID = `EXTContrib-${extension.identifier.value}`;
const contributedCategories = new Map();
extension.contributes?.welcomeCategories?.forEach(category => {
const categoryID = extension.identifier.value + '.' + category.id;
contributedCategories.set(category.id, categoryID);
this.registry.registerCategory({
content: { type: 'items' },
description: localize('extContrib', "Learn more about {0}!", extension.displayName ?? extension.name),
title: extension.displayName || extension.name,
description: category.description,
title: category.title,
id: categoryID,
icon: {
type: 'image',
@ -156,22 +159,31 @@ export class GettingStartedService extends Disposable implements IGettingStarted
? FileAccess.asBrowserUri(joinPath(extension.extensionLocation, extension.icon)).toString(true)
: DefaultIconPath
},
when: ContextKeyExpr.true(),
when: ContextKeyExpr.deserialize(category.when) ?? ContextKeyExpr.true(),
});
extension.contributes?.gettingStarted.forEach((content, index) => {
});
Object.entries(extension.contributes?.welcomeItems ?? {}).forEach(([category, items]) =>
items.forEach((item, 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: categoryID,
button: item.button,
description: item.description,
media: { type: 'image', altText: item.media.altText, path: convertPaths(item.media.path) },
doneOn: item.doneOn?.event
? { eventFired: item.doneOn.event }
: item.doneOn?.command ?
{ commandExecuted: item.doneOn.command }
: item.button.command
? { commandExecuted: item.button.command }
: { eventFired: `linkOpened:${item.button.link}` },
id: extension.identifier.value + '.' + item.id,
title: item.title,
when: ContextKeyExpr.deserialize(item.when) ?? ContextKeyExpr.true(),
category: contributedCategories.get(category) ?? category,
order: index,
});
});
}
})
)
);
}
}
@ -241,7 +253,7 @@ export class GettingStartedService extends Disposable implements IGettingStarted
};
}
private progressTask(id: string) {
progressTask(id: string) {
const oldProgress = this.taskProgress[id];
if (!oldProgress || oldProgress.done !== true) {
this.taskProgress[id] = { done: true };