diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index cbc5c0bb1bb..9ec898986a1 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -373,6 +373,10 @@ { "name": "vs/workbench/services/extensionRecommendations", "project": "vscode-workbench" + }, + { + "name": "vs/workbench/services/gettingStarted", + "project": "vscode-workbench" } ] } diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index cdeb92b8da4..b4740dadea0 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1373,8 +1373,8 @@ export function safeInnerHtml(node: HTMLElement, value: string): void { const options = _extInsaneOptions({ allowedTags: ['a', 'button', 'blockquote', 'code', 'div', 'h1', 'h2', 'h3', 'input', 'label', 'li', 'p', 'pre', 'select', 'small', 'span', 'strong', 'textarea', 'ul', 'ol'], allowedAttributes: { - 'a': ['href'], - 'button': ['data-href'], + 'a': ['href', 'x-dispatch'], + 'button': ['data-href', 'x-dispatch'], 'input': ['type', 'placeholder', 'checked', 'required'], 'label': ['for'], 'select': ['required'], diff --git a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts index 465497930d7..ca1165af6ad 100644 --- a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts +++ b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts @@ -22,6 +22,7 @@ import { onUnexpectedError } from 'vs/base/common/errors'; import { IQuickInputService, QuickPickInput } from 'vs/platform/quickinput/common/quickInput'; import { ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; import { DEFAULT_PRODUCT_ICON_THEME_ID } from 'vs/workbench/services/themes/browser/productIconThemeData'; +import { IGettingStartedService } from 'vs/workbench/services/gettingStarted/common/gettingStartedService'; export class SelectColorThemeAction extends Action { @@ -34,6 +35,7 @@ export class SelectColorThemeAction extends Action { @IQuickInputService private readonly quickInputService: IQuickInputService, @IWorkbenchThemeService private readonly themeService: IWorkbenchThemeService, @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, + @IGettingStartedService private readonly gettingStartedService: IGettingStartedService, @IViewletService private readonly viewletService: IViewletService ) { super(id, label); @@ -84,6 +86,7 @@ export class SelectColorThemeAction extends Action { openExtensionViewlet(this.viewletService, `category:themes ${quickpick.value}`); } else { selectTheme(theme, true); + this.gettingStartedService.progressByEvent('themeSelected'); } isCompleted = true; quickpick.hide(); diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.contribution.ts b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.contribution.ts new file mode 100644 index 00000000000..d3cd0749b27 --- /dev/null +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.contribution.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 { localize } from 'vs/nls'; +import { GettingStartedInputFactory, GettingStartedPage } from 'vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { Extensions as EditorInputExtensions, IEditorInputFactoryRegistry } from 'vs/workbench/common/editor'; +import { MenuId, registerAction2, Action2 } from 'vs/platform/actions/common/actions'; +import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry'; +import { workbenchConfigurationNodeBase } from 'vs/workbench/common/configuration'; +import product from 'vs/platform/product/common/product'; + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.showGettingStarted', + title: localize('Getting Started', "Getting Started"), + category: localize('help', "Help"), + f1: true, + precondition: ContextKeyExpr.has('config.workbench.experimental.gettingStarted'), + menu: { + id: MenuId.MenubarHelpMenu, + when: ContextKeyExpr.has('config.workbench.experimental.gettingStarted'), + group: '1_welcome', + order: 2, + } + }); + } + + public run(accessor: ServicesAccessor) { + return accessor.get(IInstantiationService).createInstance(GettingStartedPage).openEditor(); + } +}); + +Registry.as(EditorInputExtensions.EditorInputFactories).registerEditorInputFactory(GettingStartedInputFactory.ID, GettingStartedInputFactory); + +if (product.quality !== 'stable') { + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.experimental.gettingStarted': { + type: 'boolean', + description: localize('gettingStartedDescription', "Enables an experimental Getting Started page, accesible via the Help menu."), + default: false, + } + } + }); +} diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.css b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.css new file mode 100644 index 00000000000..7b4b0c101b0 --- /dev/null +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.css @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.file-icons-enabled .show-file-icons .vs_code_editor_getting_started\.md-name-file-icon.md-ext-file-icon.ext-file-icon.markdown-lang-file-icon.file-icon::before { + content: ' '; + background-image: url('../../../../browser/media/code-icon.svg'); +} + + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide { + width: 100%; + position: absolute; + transition: left 0.25s; + left: 0; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories .header { + text-align: center; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories .title { + display: inline-block; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories .category-title { + margin-top: 6px; + margin-bottom: 2px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories .category-description-container { + width: 100% +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories .category-description { + font-size: 12pt; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories .category-progress { + margin-top: 6px; + font-size: 8pt; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories progress { + font-size: 12pt; + width: 100%; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories #getting-started-categories-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + width: 70%; + max-width: 900px; + margin: 20px auto; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide .getting-started-category { + width: 330px; + height: 100px; + text-align: left; + display: flex; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.categories .getting-started-category { + padding-right: 46px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide .getting-started-category .codicon { + margin: 10px 8px 0 0; + font-size: 32px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-category { + width: 330px; + height: 100px; + display: flex; + margin-left: 12px; + +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-category .codicon { + margin-left:0; + margin-top: 28px; + font-size: 22pt; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail #getting-started-detail-columns { + display: flex; + justify-content: center; + padding: 30px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task { + width: 100%; + height: 26pt; + overflow: hidden; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task.expanded { + width: 100%; + height: unset; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task .task-description-container { + padding-left: 30px; + padding-right: 4px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task .task-title { + margin-bottom: 4px; + font-size: 14pt; +} + + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task .task-description { + font-size: 11pt; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task .task-next { + float: right; + margin-top: 16px; + margin-right: 25px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task .codicon.hidden { + display: none; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task .codicon { + float: left; + font-size: 20pt; + position: relative; + top: -4px; + left: -1px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task-action { + margin: 10px 0 0; + padding: 4px 8px; + font-size: 11pt; + min-width: 100px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail #getting-started-detail-left { + min-width: 330px; + width: 33%; + max-width: 400px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail #getting-started-detail-right { + width: 66%; + text-align: center; + padding: 35px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button { + border: none; + margin: 10px; + color: inherit; + text-align: left; + padding: 10px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .prev-button { + position: absolute; + left: 0; + font-size: 12pt; + margin: 10px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .prev-button .codicon { + position: relative; + top: 2px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide .product-icon { + background-image: url('../../../../browser/media/code-icon.svg'); + width: 75px; + height: 75px; + display: inline-block; + margin-right: 20px; +} + + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide .skip { + display: block; + width: 150px; + margin: 0 auto; + text-align: center; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide h1 { + font-size: 32pt; + font-weight: normal; + border-bottom: none; + margin-bottom: 0; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide h2 { + font-weight: normal; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide h3 { + font-weight: normal; + margin-top: 0; + margin-bottom: 0; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide .subtitle { + font-size: 18pt; + margin-top: 0; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .footer { + position: absolute; + text-align: center; + bottom: 0; + width: 100%; + margin-bottom: 20px; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.next { + left: 100%; +} + +.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.prev { + left: -100%; +} diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts new file mode 100644 index 00000000000..8538393dbbb --- /dev/null +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.ts @@ -0,0 +1,394 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./gettingStarted'; +import 'vs/workbench/contrib/welcome/gettingStarted/browser/vs_code_editor_getting_started'; +import { localize } from 'vs/nls'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { WalkThroughInput } from 'vs/workbench/contrib/welcome/walkThrough/browser/walkThroughInput'; +import { FileAccess, Schemas } from 'vs/base/common/network'; +import { IEditorInputFactory, EditorInput } from 'vs/workbench/common/editor'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IEditorOptions } from 'vs/platform/editor/common/editor'; +import { assertIsDefined } from 'vs/base/common/types'; +import { $, addDisposableListener } from 'vs/base/browser/dom'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IProductService } from 'vs/platform/product/common/productService'; +import { IGettingStartedCategoryWithProgress, IGettingStartedService } from 'vs/workbench/services/gettingStarted/common/gettingStartedService'; +import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { buttonBackground as welcomeButtonBackground, buttonHoverBackground as welcomeButtonHoverBackground, welcomePageBackground } from 'vs/workbench/contrib/welcome/page/browser/welcomePageColors'; +import { activeContrastBorder, buttonBackground, buttonForeground, buttonHoverBackground, contrastBorder, descriptionForeground, focusBorder, foreground, textLinkActiveForeground, textLinkForeground } from 'vs/platform/theme/common/colorRegistry'; +import { getExtraColor } from 'vs/workbench/contrib/welcome/walkThrough/common/walkThroughUtils'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; + +export const gettingStartedInputTypeId = 'workbench.editors.gettingStartedInput'; +const telemetryFrom = 'gettingStartedPage'; + +export class GettingStartedPage extends Disposable { + readonly editorInput: WalkThroughInput; + private inProgressScroll = Promise.resolve(); + + private dispatchListeners = new DisposableStore(); + + private gettingStartedCategories: IGettingStartedCategoryWithProgress[]; + private currentCategory: IGettingStartedCategoryWithProgress | undefined; + + + + constructor( + @IEditorService private readonly editorService: IEditorService, + @ICommandService private readonly commandService: ICommandService, + @IProductService private readonly productService: IProductService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IGettingStartedService private readonly gettingStartedService: IGettingStartedService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IInstantiationService private readonly instantiationService: IInstantiationService) { + super(); + + const resource = FileAccess.asBrowserUri('./vs_code_editor_getting_started.md', require) + .with({ + scheme: Schemas.walkThrough, + query: JSON.stringify({ moduleId: 'vs/workbench/contrib/welcome/gettingStarted/browser/vs_code_editor_getting_started' }) + }); + + + this.editorInput = this.instantiationService.createInstance(WalkThroughInput, { + typeId: gettingStartedInputTypeId, + name: localize('editorGettingStarted.title', "Getting Started"), + resource, + telemetryFrom, + onReady: (container: HTMLElement) => this.onReady(container) + }); + + 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.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); + if (!category) { throw Error('Could not find category with ID: ' + task.category); } + if (category.content.type !== 'items') { throw Error('internaal error: progressing task in a non-items category'); } + const ourTask = category.content.items.find(_task => _task.id === task.id); + if (!ourTask) { + throw Error('Could not find task with ID: ' + task.id); + } + ourTask.done = task.done; + if (category.id === this.currentCategory?.id) { + const badgeelement = assertIsDefined(document.getElementById('done-task-' + task.id)); + if (task.done) { + badgeelement.classList.remove('codicon-star-empty'); + badgeelement.classList.add('codicon-star-full'); + } + else { + badgeelement.classList.add('codicon-star-empty'); + badgeelement.classList.remove('codicon-star-full'); + } + } + this.updateCategoryProgress(); + })); + } + + public openEditor(options: IEditorOptions = { pinned: true }) { + return this.editorService.openEditor(this.editorInput, options); + } + + private registerDispatchListeners(container: HTMLElement) { + this.dispatchListeners.clear(); + + container.querySelectorAll('[x-dispatch]').forEach(element => { + const [command, argument] = (element.getAttribute('x-dispatch') ?? '').split(':'); + if (command) { + this.dispatchListeners.add(addDisposableListener(element, 'click', (e) => { + + type GettingStartedActionClassification = { + command: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; + argument: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; + }; + type GettingStartedActionEvent = { + command: string; + argument: string | undefined; + }; + this.telemetryService.publicLog2('gettingStarted.ActionExecuted', { command, argument }); + + switch (command) { + case 'scrollPrev': { + this.scrollPrev(container); + break; + } + case 'skip': { + this.commandService.executeCommand('workbench.action.closeActiveEditor'); + break; + } + case 'selectCategory': { + const selectedCategory = this.gettingStartedCategories.find(category => category.id === argument); + if (!selectedCategory) { throw Error('Could not find category with ID ' + argument); } + if (selectedCategory.content.type === 'command') { + this.commandService.executeCommand(selectedCategory.content.command); + } else { + this.scrollToCategory(container, argument); + } + break; + } + case 'selectTask': { + this.selectTask(argument); + e.stopPropagation(); + break; + } + case 'runTaskAction': { + if (!this.currentCategory || this.currentCategory.content.type !== 'items') { + throw Error('cannot run task action for category of non items type' + this.currentCategory?.id); + } + const taskToRun = assertIsDefined(this.currentCategory?.content.items.find(task => task.id === argument)); + const commandToRun = assertIsDefined(taskToRun.button?.command); + this.commandService.executeCommand(commandToRun); + break; + } + default: { + console.error('Dispatch to', command, argument, 'not defined'); + break; + } + } + })); + } + }); + } + + private selectTask(id: string | undefined) { + const mediaElement = assertIsDefined(document.getElementById('getting-started-media')); + if (id) { + const taskElement = assertIsDefined(document.getElementById('getting-started-task-' + id)); + if (!this.currentCategory || this.currentCategory.content.type !== 'items') { + throw Error('cannot expand task for category of non items type' + this.currentCategory?.id); + } + const taskToExpand = assertIsDefined(this.currentCategory.content.items.find(task => task.id === id)); + mediaElement.setAttribute('src', taskToExpand.media.toString()); + taskElement.parentElement?.querySelectorAll('.expanded').forEach(node => node.classList.remove('expanded')); + taskElement.classList.add('expanded'); + } else { + mediaElement.setAttribute('src', ''); + } + } + + private onReady(container: HTMLElement) { + const categoryElements = this.gettingStartedCategories.map( + category => { + const categoryDescriptionElement = + category.content.type === 'items' ? + $('.category-description-container', {}, + $('h3.category-title', {}, category.title), + $('.category-description.description', {}, category.description), + $('.category-progress', { 'x-data-category-id': category.id, }, $('.message'), $('progress'))) : + $('.category-description-container', {}, + $('h3.category-title', {}, category.title), + $('.category-description.description', {}, category.description)); + + return $('button.getting-started-category', + { 'x-dispatch': 'selectCategory:' + category.id }, + $('.codicon.codicon-' + category.codicon, {}), categoryDescriptionElement); + }); + + const rightColumn = assertIsDefined(container.querySelector('#getting-started-detail-right')); + rightColumn.appendChild($('img#getting-started-media')); + + categoryElements.forEach(element => { + assertIsDefined(document.getElementById('getting-started-categories-container')).appendChild(element); + }); + + this.updateCategoryProgress(); + + assertIsDefined(document.getElementById('product-name')).textContent = this.productService.nameLong; + this.registerDispatchListeners(container); + } + + private updateCategoryProgress() { + document.querySelectorAll('.category-progress').forEach(element => { + const categoryID = element.getAttribute('x-data-category-id'); + const category = this.gettingStartedCategories.find(category => category.id === categoryID); + if (!category) { throw Error('Could not find c=ategory with ID ' + categoryID); } + if (category.content.type !== 'items') { throw Error('Category with ID ' + categoryID + ' is not of items type'); } + const numDone = category.content.items.filter(task => task.done).length; + const numTotal = category.content.items.length; + + const message = assertIsDefined(element.firstChild); + const bar = assertIsDefined(element.lastChild) as HTMLProgressElement; + bar.value = numDone; + bar.max = numTotal; + if (numTotal === numDone) { + message.textContent = `All items complete!`; + } + else { + message.textContent = `${numDone} of ${numTotal} items complete`; + } + }); + } + + private async scrollToCategory(container: HTMLElement, categoryID: string) { + this.inProgressScroll = this.inProgressScroll.then(async () => { + this.clearDetialView(); + this.currentCategory = this.gettingStartedCategories.find(category => category.id === categoryID); + if (!this.currentCategory) { throw Error('could not find category with ID ' + categoryID); } + if (this.currentCategory.content.type !== 'items') { throw Error('category with ID ' + categoryID + ' is not of items type'); } + const slides = [...container.querySelectorAll('.gettingStartedSlide').values()]; + const currentSlide = slides.findIndex(element => + !element.classList.contains('prev') && !element.classList.contains('next')); + if (currentSlide < slides.length - 1) { + slides[currentSlide].classList.add('prev'); + + const detailSlide = assertIsDefined(slides[currentSlide + 1]); + detailSlide.classList.remove('next'); + const detailTitle = assertIsDefined(document.getElementById('getting-started-detail-title')); + detailTitle.appendChild( + $('.getting-started-category', + {}, + $('.codicon.codicon-' + this.currentCategory.codicon, {}), + $('.category-description-container', {}, + $('h2.category-title', {}, this.currentCategory.title), + $('.category-description.description', {}, this.currentCategory.description)))); + + const categoryElements = this.currentCategory.content.items.map( + (task, i, arr) => + $('button.getting-started-task', + { 'x-dispatch': 'selectTask:' + task.id, id: 'getting-started-task-' + task.id }, + $('.codicon' + (task.done ? '.codicon-star-full' : '.codicon-star-empty'), { id: 'done-task-' + task.id },), + $('.task-description-container', {}, + $('h3.task-title', {}, task.title), + $('.task-description.description', {}, task.description), + ...( + task.button + ? [$('button.emphasis.getting-started-task-action', { 'x-dispatch': 'runTaskAction:' + task.id }, + task.button.title + this.getKeybindingLabel(task.button.command) + )] + : []), + ...( + arr[i + 1] + ? [ + $('a.task-next', + { 'x-dispatch': 'selectTask:' + arr[i + 1].id }, localize('next', "Next")), + ] : [] + ) + ))); + + const detailContainer = assertIsDefined(document.getElementById('getting-started-detail-container')); + categoryElements.forEach(element => detailContainer.appendChild(element)); + + const toExpand = this.currentCategory.content.items.find(item => !item.done) ?? this.currentCategory.content.items[0]; + this.selectTask(toExpand.id); + this.registerDispatchListeners(container); + } + }); + } + + private clearDetialView() { + const detailContainer = assertIsDefined(document.getElementById('getting-started-detail-container')); + while (detailContainer.firstChild) { detailContainer.removeChild(detailContainer.firstChild); } + const detailTitle = assertIsDefined(document.getElementById('getting-started-detail-title')); + while (detailTitle.firstChild) { detailTitle.removeChild(detailTitle.firstChild); } + } + + private getKeybindingLabel(command: string) { + const binding = this.keybindingService.lookupKeybinding(command); + if (!binding) { return ''; } + else { return ` (${binding.getLabel()})`; } + } + + private async scrollPrev(container: HTMLElement) { + this.inProgressScroll = this.inProgressScroll.then(async () => { + this.currentCategory = undefined; + this.selectTask(undefined); + const slides = [...container.querySelectorAll('.gettingStartedSlide').values()]; + const currentSlide = slides.findIndex(element => + !element.classList.contains('prev') && !element.classList.contains('next')); + if (currentSlide > 0) { + slides[currentSlide].classList.add('next'); + assertIsDefined(slides[currentSlide - 1]).classList.remove('prev'); + } + }); + } +} + +export class GettingStartedInputFactory implements IEditorInputFactory { + + static readonly ID = gettingStartedInputTypeId; + + public canSerialize(editorInput: EditorInput): boolean { + return true; + } + + public serialize(editorInput: EditorInput): string { + return '{}'; + } + + public deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): WalkThroughInput { + return instantiationService.createInstance(GettingStartedPage).editorInput; + } +} + +registerThemingParticipant((theme, collector) => { + const backgroundColor = theme.getColor(welcomePageBackground); + if (backgroundColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .welcomePageContainer { background-color: ${backgroundColor}; }`); + } + const foregroundColor = theme.getColor(foreground); + if (foregroundColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer { color: ${foregroundColor}; }`); + } + const descriptionColor = theme.getColor(descriptionForeground); + if (descriptionColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .description { color: ${descriptionColor}; }`); + } + const buttonColor = getExtraColor(theme, welcomeButtonBackground, { dark: 'rgba(0, 0, 0, .2)', extra_dark: 'rgba(200, 235, 255, .042)', light: 'rgba(0,0,0,.04)', hc: 'black' }); + if (buttonColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button { background: ${buttonColor}; }`); + } + + const buttonHoverColor = getExtraColor(theme, welcomeButtonHoverBackground, { dark: 'rgba(200, 235, 255, .072)', extra_dark: 'rgba(200, 235, 255, .072)', light: 'rgba(0,0,0,.10)', hc: null }); + if (buttonHoverColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button:hover { background: ${buttonHoverColor}; }`); + } + if (buttonColor && buttonHoverColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button.expanded:hover { background: ${buttonColor}; }`); + } + + const emphasisButtonForeground = theme.getColor(buttonForeground); + if (emphasisButtonForeground) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button.emphasis { color: ${emphasisButtonForeground}; }`); + } + + const emphasisButtonBackground = theme.getColor(buttonBackground); + if (emphasisButtonBackground) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button.emphasis { background: ${emphasisButtonBackground}; }`); + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .getting-started-category .codicon { color: ${emphasisButtonBackground} }`); + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer .gettingStartedSlide.detail .getting-started-task .codicon { color: ${emphasisButtonBackground} } `); + } + + const emphasisButtonHoverBackground = theme.getColor(buttonHoverBackground); + if (emphasisButtonHoverBackground) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button.emphasis:hover { background: ${emphasisButtonHoverBackground}; }`); + } + + const link = theme.getColor(textLinkForeground); + if (link) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer a { color: ${link}; }`); + } + const activeLink = theme.getColor(textLinkActiveForeground); + if (activeLink) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer a:hover, + .monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer a:active { color: ${activeLink}; }`); + } + const focusColor = theme.getColor(focusBorder); + if (focusColor) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer a:focus { outline-color: ${focusColor}; }`); + } + const border = theme.getColor(contrastBorder); + if (border) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button { border-color: ${border}; border: 1px solid; }`); + } + const activeBorder = theme.getColor(activeContrastBorder); + if (activeBorder) { + collector.addRule(`.monaco-workbench .part.editor > .content .walkThroughContent .gettingStartedContainer button:hover { outline-color: ${activeBorder}; }`); + } +}); diff --git a/src/vs/workbench/contrib/welcome/gettingStarted/browser/vs_code_editor_getting_started.ts b/src/vs/workbench/contrib/welcome/gettingStarted/browser/vs_code_editor_getting_started.ts new file mode 100644 index 00000000000..6559c246fc9 --- /dev/null +++ b/src/vs/workbench/contrib/welcome/gettingStarted/browser/vs_code_editor_getting_started.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { escape } from 'vs/base/common/strings'; +import { localize } from 'vs/nls'; + +export default () => ` +
+
+
+
+
+
+

${escape(localize('gettingStarted.vscode', "Visual Studio Code"))}

+

${escape(localize({ key: 'gettingStarted.editingRedefined', comment: ['Shown as subtitle on the Welcome page.'] }, "Code editing. Redefined"))}

+
+
+
+
+ + +
+
+`.replace(/\|/g, '`'); diff --git a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts index 396871dc396..1a59cbb17c4 100644 --- a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts +++ b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.contribution.ts @@ -13,6 +13,7 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions, Configur import { IEditorInputFactoryRegistry, Extensions as EditorExtensions } from 'vs/workbench/common/editor'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import { workbenchConfigurationNodeBase } from 'vs/workbench/common/configuration'; +import product from 'vs/platform/product/common/product'; Registry.as(ConfigurationExtensions.Configuration) .registerConfiguration({ @@ -21,13 +22,21 @@ Registry.as(ConfigurationExtensions.Configuration) 'workbench.startupEditor': { 'scope': ConfigurationScope.APPLICATION, // Make sure repositories cannot trigger opening a README for tracking. 'type': 'string', - 'enum': ['none', 'welcomePage', 'readme', 'newUntitledFile', 'welcomePageInEmptyWorkbench'], - 'enumDescriptions': [ + 'enum': [ + ...['none', 'welcomePage', 'readme', 'newUntitledFile', 'welcomePageInEmptyWorkbench'], + ...(product.quality !== 'stable' + ? ['gettingStarted'] + : []) + ], + 'enumDescriptions': [...[ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.none' }, "Start without an editor."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePage' }, "Open the Welcome page (default)."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.readme' }, "Open the README when opening a folder that contains one, fallback to 'welcomePage' otherwise."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.newUntitledFile' }, "Open a new untitled file (only applies when opening an empty workspace)."), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePageInEmptyWorkbench' }, "Open the Welcome page when opening an empty workbench."), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePageInEmptyWorkbench' }, "Open the Welcome page when opening an empty workbench."),], + ...(product.quality !== 'stable' + ? [localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.gettingStarted' }, "Open the Getting Started page (experimental).")] + : []) ], 'default': 'welcomePage', 'description': localize('workbench.startupEditor', "Controls which editor is shown at startup, if none are restored from the previous session.") diff --git a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts index c9c0f00f5d7..790fe57fe26 100644 --- a/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts +++ b/src/vs/workbench/contrib/welcome/page/browser/welcomePage.ts @@ -29,7 +29,7 @@ import { ILifecycleService, StartupKind } from 'vs/workbench/services/lifecycle/ import { Disposable } from 'vs/base/common/lifecycle'; import { splitName } from 'vs/base/common/labels'; import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; -import { registerColor, focusBorder, textLinkForeground, textLinkActiveForeground, foreground, descriptionForeground, contrastBorder, activeContrastBorder } from 'vs/platform/theme/common/colorRegistry'; +import { focusBorder, textLinkForeground, textLinkActiveForeground, foreground, descriptionForeground, contrastBorder, activeContrastBorder } from 'vs/platform/theme/common/colorRegistry'; import { getExtraColor } from 'vs/workbench/contrib/welcome/walkThrough/common/walkThroughUtils'; import { IExtensionsViewPaneContainer, IExtensionsWorkbenchService, VIEWLET_ID } from 'vs/workbench/contrib/extensions/common/extensions'; import { IEditorInputFactory, EditorInput } from 'vs/workbench/common/editor'; @@ -46,6 +46,8 @@ import { IProductService } from 'vs/platform/product/common/productService'; import { IEditorOptions } from 'vs/platform/editor/common/editor'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; +import { gettingStartedInputTypeId, GettingStartedPage } from 'vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted'; +import { buttonBackground, buttonHoverBackground, welcomePageBackground } from 'vs/workbench/contrib/welcome/page/browser/welcomePageColors'; const configurationKey = 'workbench.startupEditor'; const oldConfigurationKey = 'workbench.welcome.enabled'; @@ -69,7 +71,8 @@ export class WelcomePageContribution implements IWorkbenchContribution { backupFileService.hasBackups().then(hasBackups => { // Open the welcome even if we opened a set of default editors if ((!editorService.activeEditor || layoutService.openedDefaultEditors) && !hasBackups) { - const openWithReadme = configurationService.getValue(configurationKey) === 'readme'; + const startupEditorSetting = configurationService.getValue(configurationKey) as string; + const openWithReadme = startupEditorSetting === 'readme'; if (openWithReadme) { return Promise.all(contextService.getWorkspace().folders.map(folder => { const folderUri = folder.uri; @@ -101,18 +104,21 @@ export class WelcomePageContribution implements IWorkbenchContribution { return undefined; }); } else { + const startupEditorTypeID = startupEditorSetting === 'gettingStarted' ? gettingStartedInputTypeId : welcomeInputTypeId; + const startupEditorCtor = startupEditorSetting === 'gettingStarted' ? GettingStartedPage : WelcomePage; + let options: IEditorOptions; let editor = editorService.activeEditor; if (editor) { // Ensure that the welcome editor won't get opened more than once - if (editor.getTypeId() === welcomeInputTypeId || editorService.editors.some(e => e.getTypeId() === welcomeInputTypeId)) { + if (editor.getTypeId() === startupEditorTypeID || editorService.editors.some(e => e.getTypeId() === startupEditorTypeID)) { return undefined; } options = { pinned: false, index: 0 }; } else { options = { pinned: false }; } - return instantiationService.createInstance(WelcomePage).openEditor(options); + return instantiationService.createInstance(startupEditorCtor).openEditor(options); } } return undefined; @@ -129,7 +135,7 @@ function isWelcomePageEnabled(configurationService: IConfigurationService, conte return welcomeEnabled.value; } } - return startupEditor.value === 'welcomePage' || startupEditor.value === 'readme' || startupEditor.value === 'welcomePageInEmptyWorkbench' && contextService.getWorkbenchState() === WorkbenchState.EMPTY; + return startupEditor.value === 'welcomePage' || startupEditor.value === 'gettingStarted' || startupEditor.value === 'readme' || startupEditor.value === 'welcomePageInEmptyWorkbench' && contextService.getWorkbenchState() === WorkbenchState.EMPTY; } export class WelcomePageAction extends Action { @@ -639,10 +645,6 @@ export class WelcomeInputFactory implements IEditorInputFactory { // theming -export const buttonBackground = registerColor('welcomePage.buttonBackground', { dark: null, light: null, hc: null }, localize('welcomePage.buttonBackground', 'Background color for the buttons on the Welcome page.')); -export const buttonHoverBackground = registerColor('welcomePage.buttonHoverBackground', { dark: null, light: null, hc: null }, localize('welcomePage.buttonHoverBackground', 'Hover background color for the buttons on the Welcome page.')); -export const welcomePageBackground = registerColor('welcomePage.background', { light: null, dark: null, hc: null }, localize('welcomePage.background', 'Background color for the Welcome page.')); - registerThemingParticipant((theme, collector) => { const backgroundColor = theme.getColor(welcomePageBackground); if (backgroundColor) { diff --git a/src/vs/workbench/contrib/welcome/page/browser/welcomePageColors.ts b/src/vs/workbench/contrib/welcome/page/browser/welcomePageColors.ts new file mode 100644 index 00000000000..e87483cf446 --- /dev/null +++ b/src/vs/workbench/contrib/welcome/page/browser/welcomePageColors.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerColor } from 'vs/platform/theme/common/colorRegistry'; +import { localize } from 'vs/nls'; + +// Seprate from main module to break dependency cycles between welcomePage and gettingStarted. +export const buttonBackground = registerColor('welcomePage.buttonBackground', { dark: null, light: null, hc: null }, localize('welcomePage.buttonBackground', 'Background color for the buttons on the Welcome page.')); +export const buttonHoverBackground = registerColor('welcomePage.buttonHoverBackground', { dark: null, light: null, hc: null }, localize('welcomePage.buttonHoverBackground', 'Hover background color for the buttons on the Welcome page.')); +export const welcomePageBackground = registerColor('welcomePage.background', { light: null, dark: null, hc: null }, localize('welcomePage.background', 'Background color for the Welcome page.')); diff --git a/src/vs/workbench/services/gettingStarted/common/gettingStartedContent.ts b/src/vs/workbench/services/gettingStarted/common/gettingStartedContent.ts new file mode 100644 index 00000000000..77257caa287 --- /dev/null +++ b/src/vs/workbench/services/gettingStarted/common/gettingStartedContent.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; + +type GettingStartedItem = { + id: string + title: string, + description: string, + button: { title: string, command: string }, + doneOn: { commandExecuted: string, eventFired?: never } | { eventFired: string, commandExecuted?: never, } + when?: string, + media: string, +}; + +type GettingStartedCategory = { + id: string + title: string, + description: string, + codicon: string, + when?: string, + content: + | { type: 'items', items: GettingStartedItem[] } + | { type: 'command', command: string } +}; + +type GettingStartedContent = GettingStartedCategory[]; + +export const content: GettingStartedContent = [ + { + id: 'Beginner', + title: localize('gettingStarted.beginner.title', "Get Started"), + codicon: 'lightbulb', + description: localize('gettingStarted.beginner.description', "Get to know your new Editor"), + content: { + type: 'items', + items: [ + { + id: 'pickColorTheme', + description: localize('pickColorTask.description', "Modify the colors in the user interface to suit your preferences and work environment."), + title: localize('pickColorTask.title', "Color Theme"), + button: { title: localize('pickColorTask.button', "Find a Theme"), command: 'workbench.action.selectTheme' }, + doneOn: { eventFired: 'themeSelected' }, + media: 'Square.png' + }, + + { + id: 'findKeybindingsExtensions', + description: localize('findKeybindingsTask.description', "Find keyboard shortcuts for Vim, Sublime, Atom and others."), + title: localize('findKeybindingsTask.title', "Configure Keybindings"), + button: { + title: localize('findKeybindingsTask.button', "Search for Keymaps"), + command: 'workbench.extensions.action.showRecommendedKeymapExtensions' + }, + doneOn: { commandExecuted: 'workbench.extensions.action.showRecommendedKeymapExtensions' }, + media: 'Tall.png', + }, + + { + id: 'findLanguageExtensions', + description: localize('findLanguageExtsTask.description', "Get support for your languages like JavaScript, Python, Java, Azure, Docker, and more."), + title: localize('findLanguageExtsTask.title', "Languages & Tools"), + button: { + title: localize('findLanguageExtsTask.button', "Install Language Support"), + command: 'workbench.extensions.action.showLanguageExtensions', + }, + doneOn: { commandExecuted: 'workbench.extensions.action.showLanguageExtensions' }, + media: 'Short.png', + }, + + { + id: 'pickAFolderTask-Mac', + description: localize('gettingStartedOpenFolder.description', "Open a project folder to get started!"), + title: localize('gettingStartedOpenFolder.title', "Open Folder"), + when: 'isMac', + button: { + title: localize('gettingStartedOpenFolder.button', "Pick a Folder"), + command: 'workbench.action.files.openFileFolder' + }, + doneOn: { commandExecuted: 'workbench.action.files.openFileFolder' }, + media: 'Square.png' + }, + + { + id: 'pickAFolderTask-Other', + description: localize('gettingStartedOpenFolder.description', "Open a project folder to get started!"), + title: localize('gettingStartedOpenFolder.title', "Open Folder"), + when: '!isMac', + button: { + title: localize('gettingStartedOpenFolder.button', "Pick a Folder"), + command: 'workbench.action.files.openFolder' + }, + doneOn: { commandExecuted: 'workbench.action.files.openFolder' }, + media: 'Square.png' + } + ] + } + }, + + { + id: 'Intermediate', + title: localize('gettingStarted.intermediate.title', "Essentials"), + codicon: 'heart', + description: localize('gettingStarted.intermediate.description', "Must know features you'll love"), + content: { + type: 'items', + items: [ + { + id: 'commandPaletteTask', + description: localize('commandPaletteTask.description', "The easiest way to find everything VS Code can do. If you\'re ever looking for a feature, check here first!"), + title: localize('commandPaletteTask.title', "Command Palette"), + button: { + title: localize('commandPaletteTask.button', "View All Commands"), + command: 'workbench.action.showCommands' + }, + doneOn: { commandExecuted: 'workbench.action.showCommands' }, + media: 'https://code.visualstudio.com/assets/updates/1_51/custom-tree-hover.gif', + } + ] + } + }, + + { + id: 'Advanced', + title: localize('gettingStarted.advanced.title', "Tips & Tricks"), + codicon: 'tools', + description: localize('gettingStarted.advanced.description', "Favorites from VS Code experts"), + content: { + type: 'items', + items: [] + } + }, + + { + id: 'OpenFolder-Mac', + title: localize('gettingStarted.openFolder.title', "Open Folder"), + codicon: 'folder-opened', + when: 'isMac', + description: localize('gettingStarted.openFolder.description', "Open a project and start working"), + content: { + type: 'command', + command: 'workbench.action.files.openFileFolder' + } + }, + + { + id: 'OpenFolder-Other', + title: localize('gettingStarted.openFolder.title', "Open Folder"), + codicon: 'folder-opened', + description: localize('gettingStarted.openFolder.description', "Open a project and start working"), + when: '!isMac', + content: { + type: 'command', + command: 'workbench.action.files.openFolder' + } + }, + + { + id: 'InteractivePlayground', + title: localize('gettingStarted.playground.title', "Interactive Playground"), + codicon: 'library', + description: localize('gettingStarted.interactivePlayground.description', "Learn essential editor features"), + content: { + type: 'command', + command: 'workbench.action.showInteractivePlayground' + } + } +]; diff --git a/src/vs/workbench/services/gettingStarted/common/gettingStartedRegistry.ts b/src/vs/workbench/services/gettingStarted/common/gettingStartedRegistry.ts new file mode 100644 index 00000000000..ace92e7ef45 --- /dev/null +++ b/src/vs/workbench/services/gettingStarted/common/gettingStartedRegistry.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { Registry } from 'vs/platform/registry/common/platform'; +import { content } from 'vs/workbench/services/gettingStarted/common/gettingStartedContent'; + +export const enum GettingStartedCategory { + Beginner = 'Beginner', + Intermediate = 'Intermediate', + Advanced = 'Advanced' +} + +export interface IGettingStartedTask { + id: string, + title: string, + description: string, + category: GettingStartedCategory | string, + when: ContextKeyExpression, + order: number, + button: { title: string, command: string }, + doneOn: { commandExecuted: string, eventFired?: never } | { eventFired: string, commandExecuted?: never, } + media: URI +} + +export interface IGettingStartedCategoryDescriptor { + id: GettingStartedCategory | string + title: string + description: string + codicon: string + when: ContextKeyExpression + content: + | { type: 'items' } + | { type: 'command', command: string } +} + +export interface IGettingStartedCategory { + id: GettingStartedCategory | string + title: string + description: string + codicon: string + when: ContextKeyExpression + content: + | { type: 'items', items: IGettingStartedTask[] } + | { type: 'command', command: string } +} + +export interface IGettingStartedRegistry { + onDidAddCategory: Event + onDidAddTask: Event + + registerTask(task: IGettingStartedTask): IGettingStartedTask; + getTask(id: string): IGettingStartedTask + + registerCategory(categoryDescriptor: IGettingStartedCategoryDescriptor): void + getCategory(id: GettingStartedCategory | string): Readonly | undefined + + getCategories(): readonly Readonly[] +} + +export class GettingStartedRegistryImpl implements IGettingStartedRegistry { + private readonly _onDidAddTask = new Emitter(); + onDidAddTask: Event = this._onDidAddTask.event; + private readonly _onDidAddCategory = new Emitter(); + onDidAddCategory: Event = this._onDidAddCategory.event; + + private readonly gettingStartedContributions = new Map(); + private readonly tasks = new Map(); + + public registerTask(task: IGettingStartedTask): IGettingStartedTask { + const category = this.gettingStartedContributions.get(task.category); + if (!category) { throw Error('Registering getting started task to category that does not exist (' + task.category + ')'); } + if (category.content.type !== 'items') { throw Error('Registering getting started task to category that is not of `items` type (' + task.category + ')'); } + if (this.tasks.has(task.id)) { throw Error('Attempting to register task with id ' + task.id + ' twice. Second is dropped.'); } + this.tasks.set(task.id, task); + category.content.items.push(task); + this._onDidAddTask.fire(task); + return task; + } + + public registerCategory(categoryDescriptor: IGettingStartedCategoryDescriptor): void { + const oldCategory = this.gettingStartedContributions.get(categoryDescriptor.id); + if (oldCategory) { + console.error(`Skipping attempt to overwrite getting started category. (${categoryDescriptor})`); + return; + } + + const category: IGettingStartedCategory = { + ...categoryDescriptor, + content: categoryDescriptor.content.type === 'items' + ? { type: 'items', items: [] } + : categoryDescriptor.content + }; + + this.gettingStartedContributions.set(categoryDescriptor.id, category); + this._onDidAddCategory.fire(category); + } + + public getCategory(id: GettingStartedCategory | string): Readonly | undefined { + return this.gettingStartedContributions.get(id); + } + + public getTask(id: string): IGettingStartedTask { + const task = this.tasks.get(id); + if (!task) { throw Error('Attempting to access task which does not exist in registry ' + id); } + return task; + } + + public getCategories(): readonly Readonly[] { + return [...this.gettingStartedContributions.values()]; + + } +} + +export const GettingStartedRegistryID = 'GettingStartedRegistry'; +const registryImpl = new GettingStartedRegistryImpl(); + +content.forEach(category => { + + registryImpl.registerCategory({ + ...category, + when: ContextKeyExpr.deserialize(category.when) ?? ContextKeyExpr.true() + }); + + if (category.content.type === 'items') { + category.content.items.forEach((item, index) => { + registryImpl.registerTask({ + ...item, + category: category.id, + order: index, + when: ContextKeyExpr.deserialize(item.when) ?? ContextKeyExpr.true(), + media: item.media.startsWith('https://') + ? URI.parse(item.media, true) + : FileAccess.asFileUri('vs/workbench/services/gettingStarted/common/media/' + item.media, require) + }); + }); + } +}); + +Registry.add(GettingStartedRegistryID, registryImpl); +export const GettingStartedRegistry: IGettingStartedRegistry = Registry.as(GettingStartedRegistryID); diff --git a/src/vs/workbench/services/gettingStarted/common/gettingStartedService.ts b/src/vs/workbench/services/gettingStarted/common/gettingStartedService.ts new file mode 100644 index 00000000000..6e0476e5ccc --- /dev/null +++ b/src/vs/workbench/services/gettingStarted/common/gettingStartedService.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { Emitter, Event } from 'vs/base/common/event'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IGettingStartedTask, GettingStartedRegistry, IGettingStartedCategory, } from 'vs/workbench/services/gettingStarted/common/gettingStartedRegistry'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +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'; + +export const IGettingStartedService = createDecorator('gettingStartedService'); + +type TaskProgress = { done: boolean; }; +export interface IGettingStartedTaskWithProgress extends IGettingStartedTask, TaskProgress { } + +export interface IGettingStartedCategoryWithProgress extends Omit { + content: + | { + type: 'items', + items: IGettingStartedTaskWithProgress[], + done: boolean; + stepsComplete: number + stepsTotal: number + } + | { type: 'command', command: string } +} + +export interface IGettingStartedService { + _serviceBrand: undefined, + + readonly onDidAddTask: Event + readonly onDidAddCategory: Event + + readonly onDidProgressTask: Event + + getCategories(): IGettingStartedCategoryWithProgress[] + + progressByEvent(eventName: string): void; +} + +export class GettingStartedService implements IGettingStartedService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidAddTask = new Emitter(); + onDidAddTask: Event = this._onDidAddTask.event; + private readonly _onDidAddCategory = new Emitter(); + onDidAddCategory: Event = this._onDidAddCategory.event; + + private readonly _onDidProgressTask = new Emitter(); + onDidProgressTask: Event = this._onDidProgressTask.event; + + private registry = GettingStartedRegistry; + private memento: Memento; + private taskProgress: Record; + + private commandListeners = new Map(); + private eventListeners = new Map(); + + constructor( + @IStorageService private readonly storageService: IStorageService, + @ICommandService private readonly commandService: ICommandService, + @IContextKeyService private readonly contextService: IContextKeyService, + ) { + this.memento = new Memento('gettingStartedService', this.storageService); + this.taskProgress = this.memento.getMemento(StorageScope.GLOBAL, StorageTarget.USER); + + this.registry.getCategories().forEach(category => { + if (category.content.type === 'items') { + category.content.items.forEach(task => this.registerDoneListeners(task)); + } + }); + + this.registry.onDidAddCategory(category => this._onDidAddCategory.fire(this.getCategoryProgress(category))); + this.registry.onDidAddTask(task => { + this.registerDoneListeners(task); + this._onDidAddTask.fire(this.getTaskProgress(task)); + }); + + this.commandService.onDidExecuteCommand(command => this.progressByCommand(command.commandId)); + } + + private registerDoneListeners(task: IGettingStartedTask) { + if (task.doneOn.commandExecuted) { + const existing = this.commandListeners.get(task.doneOn.commandExecuted); + if (existing) { existing.push(task.id); } + else { + this.commandListeners.set(task.doneOn.commandExecuted, [task.id]); + } + } + if (task.doneOn.eventFired) { + const existing = this.eventListeners.get(task.doneOn.eventFired); + if (existing) { existing.push(task.id); } + else { + this.eventListeners.set(task.doneOn.eventFired, [task.id]); + } + } + } + + getCategories(): IGettingStartedCategoryWithProgress[] { + const registeredCategories = this.registry.getCategories(); + const categoriesWithCompletion = registeredCategories + .filter(category => this.contextService.contextMatchesRules(category.when)) + .map(category => { + if (category.content.type === 'items') { + return { + ...category, + content: { + type: 'items' as const, + items: category.content.items.filter(item => this.contextService.contextMatchesRules(item.when)) + } + }; + } + return category; + }) + .filter(category => category.content.type !== 'items' || category.content.items.length) + .map(category => this.getCategoryProgress(category)); + return categoriesWithCompletion; + } + + private getCategoryProgress(category: IGettingStartedCategory): IGettingStartedCategoryWithProgress { + if (category.content.type === 'command') { + return { ...category, content: category.content }; + } + + const tasksWithProgress = category.content.items.map(task => this.getTaskProgress(task)); + const tasksComplete = tasksWithProgress.filter(task => task.done); + + return { + ...category, + content: { + type: 'items', + items: tasksWithProgress, + stepsComplete: tasksComplete.length, + stepsTotal: tasksWithProgress.length, + done: tasksComplete.length === tasksWithProgress.length, + } + }; + } + + private getTaskProgress(task: IGettingStartedTask): IGettingStartedTaskWithProgress { + return { + ...task, + ...this.taskProgress[task.id] + }; + } + + private progressTask(id: string) { + const oldProgress = this.taskProgress[id]; + if (!oldProgress || oldProgress.done !== true) { + this.taskProgress[id] = { done: true }; + this.memento.saveMemento(); + const task = this.registry.getTask(id); + this._onDidProgressTask.fire(this.getTaskProgress(task)); + } + } + + private progressByCommand(command: string) { + const listening = this.commandListeners.get(command) ?? []; + listening.forEach(id => this.progressTask(id)); + } + + progressByEvent(event: string): void { + const listening = this.eventListeners.get(event) ?? []; + console.log(event, listening, this.eventListeners); + listening.forEach(id => this.progressTask(id)); + } +} + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'resetGettingStartedProgress', + category: 'Getting Started', + title: 'Reset Progress', + f1: true + }); + } + + run(accessor: ServicesAccessor) { + const memento = new Memento('gettingStartedService', accessor.get(IStorageService)); + const record = memento.getMemento(StorageScope.GLOBAL, StorageTarget.USER); + for (const key in record) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + delete record[key]; + } + } + memento.saveMemento(); + } +}); + +registerSingleton(IGettingStartedService, GettingStartedService); diff --git a/src/vs/workbench/services/gettingStarted/common/media/Short.png b/src/vs/workbench/services/gettingStarted/common/media/Short.png new file mode 100644 index 00000000000..e2a51789847 Binary files /dev/null and b/src/vs/workbench/services/gettingStarted/common/media/Short.png differ diff --git a/src/vs/workbench/services/gettingStarted/common/media/Square.png b/src/vs/workbench/services/gettingStarted/common/media/Square.png new file mode 100644 index 00000000000..b3c539880a5 Binary files /dev/null and b/src/vs/workbench/services/gettingStarted/common/media/Square.png differ diff --git a/src/vs/workbench/services/gettingStarted/common/media/Tall.png b/src/vs/workbench/services/gettingStarted/common/media/Tall.png new file mode 100644 index 00000000000..174f64b5c84 Binary files /dev/null and b/src/vs/workbench/services/gettingStarted/common/media/Tall.png differ diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index a91d88311ed..ba666391193 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -276,6 +276,7 @@ import 'vs/workbench/contrib/surveys/browser/languageSurveys.contribution'; // Welcome import 'vs/workbench/contrib/welcome/overlay/browser/welcomeOverlay'; import 'vs/workbench/contrib/welcome/page/browser/welcomePage.contribution'; +import 'vs/workbench/contrib/welcome/gettingStarted/browser/gettingStarted.contribution'; import 'vs/workbench/contrib/welcome/walkThrough/browser/walkThrough.contribution'; // Call Hierarchy