Path: blob/main/src/vs/workbench/browser/parts/banner/bannerPart.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/bannerpart.css';6import { localize, localize2 } from '../../../../nls.js';7import { $, addDisposableListener, append, clearNode, EventType, isHTMLElement } from '../../../../base/browser/dom.js';8import { asCSSUrl } from '../../../../base/browser/cssValue.js';9import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';10import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';11import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';12import { IStorageService } from '../../../../platform/storage/common/storage.js';13import { IThemeService } from '../../../../platform/theme/common/themeService.js';14import { ThemeIcon } from '../../../../base/common/themables.js';15import { Part } from '../../part.js';16import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js';17import { Action } from '../../../../base/common/actions.js';18import { Link } from '../../../../platform/opener/browser/link.js';19import { MarkdownString } from '../../../../base/common/htmlContent.js';20import { Emitter } from '../../../../base/common/event.js';21import { IBannerItem, IBannerService } from '../../../services/banner/browser/bannerService.js';22import { MarkdownRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js';23import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';24import { Categories } from '../../../../platform/action/common/actionCommonCategories.js';25import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';26import { KeyCode } from '../../../../base/common/keyCodes.js';27import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';28import { URI } from '../../../../base/common/uri.js';29import { widgetClose } from '../../../../platform/theme/common/iconRegistry.js';30import { BannerFocused } from '../../../common/contextkeys.js';3132// Banner Part3334export class BannerPart extends Part implements IBannerService {3536declare readonly _serviceBrand: undefined;3738// #region IView3940readonly height: number = 26;41readonly minimumWidth: number = 0;42readonly maximumWidth: number = Number.POSITIVE_INFINITY;4344get minimumHeight(): number {45return this.visible ? this.height : 0;46}4748get maximumHeight(): number {49return this.visible ? this.height : 0;50}5152private _onDidChangeSize = this._register(new Emitter<{ width: number; height: number } | undefined>());53override get onDidChange() { return this._onDidChangeSize.event; }5455//#endregion5657private item: IBannerItem | undefined;58private readonly markdownRenderer: MarkdownRenderer;59private visible = false;6061private actionBar: ActionBar | undefined;62private messageActionsContainer: HTMLElement | undefined;63private focusedActionIndex: number = -1;6465constructor(66@IThemeService themeService: IThemeService,67@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,68@IStorageService storageService: IStorageService,69@IContextKeyService private readonly contextKeyService: IContextKeyService,70@IInstantiationService private readonly instantiationService: IInstantiationService,71) {72super(Parts.BANNER_PART, { hasTitle: false }, themeService, storageService, layoutService);7374this.markdownRenderer = this.instantiationService.createInstance(MarkdownRenderer, {});75}7677protected override createContentArea(parent: HTMLElement): HTMLElement {78this.element = parent;79this.element.tabIndex = 0;8081// Restore focused action if needed82this._register(addDisposableListener(this.element, EventType.FOCUS, () => {83if (this.focusedActionIndex !== -1) {84this.focusActionLink();85}86}));8788// Track focus89const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element));90BannerFocused.bindTo(scopedContextKeyService).set(true);9192return this.element;93}9495private close(item: IBannerItem): void {96// Hide banner97this.setVisibility(false);9899// Remove from document100clearNode(this.element);101102// Remember choice103if (typeof item.onClose === 'function') {104item.onClose();105}106107this.item = undefined;108}109110private focusActionLink(): void {111const length = this.item?.actions?.length ?? 0;112113if (this.focusedActionIndex < length) {114const actionLink = this.messageActionsContainer?.children[this.focusedActionIndex];115if (isHTMLElement(actionLink)) {116this.actionBar?.setFocusable(false);117actionLink.focus();118}119} else {120this.actionBar?.focus(0);121}122}123124private getAriaLabel(item: IBannerItem): string | undefined {125if (item.ariaLabel) {126return item.ariaLabel;127}128if (typeof item.message === 'string') {129return item.message;130}131132return undefined;133}134135private getBannerMessage(message: MarkdownString | string): HTMLElement {136if (typeof message === 'string') {137const element = $('span');138element.textContent = message;139return element;140}141142return this.markdownRenderer.render(message).element;143}144145private setVisibility(visible: boolean): void {146if (visible !== this.visible) {147this.visible = visible;148this.focusedActionIndex = -1;149150this.layoutService.setPartHidden(!visible, Parts.BANNER_PART);151this._onDidChangeSize.fire(undefined);152}153}154155focus(): void {156this.focusedActionIndex = -1;157this.element.focus();158}159160focusNextAction(): void {161const length = this.item?.actions?.length ?? 0;162this.focusedActionIndex = this.focusedActionIndex < length ? this.focusedActionIndex + 1 : 0;163164this.focusActionLink();165}166167focusPreviousAction(): void {168const length = this.item?.actions?.length ?? 0;169this.focusedActionIndex = this.focusedActionIndex > 0 ? this.focusedActionIndex - 1 : length;170171this.focusActionLink();172}173174hide(id: string): void {175if (this.item?.id !== id) {176return;177}178179this.setVisibility(false);180}181182show(item: IBannerItem): void {183if (item.id === this.item?.id) {184this.setVisibility(true);185return;186}187188// Clear previous item189clearNode(this.element);190191// Banner aria label192const ariaLabel = this.getAriaLabel(item);193if (ariaLabel) {194this.element.setAttribute('aria-label', ariaLabel);195}196197// Icon198const iconContainer = append(this.element, $('div.icon-container'));199iconContainer.setAttribute('aria-hidden', 'true');200201if (ThemeIcon.isThemeIcon(item.icon)) {202iconContainer.appendChild($(`div${ThemeIcon.asCSSSelector(item.icon)}`));203} else {204iconContainer.classList.add('custom-icon');205206if (URI.isUri(item.icon)) {207iconContainer.style.backgroundImage = asCSSUrl(item.icon);208}209}210211// Message212const messageContainer = append(this.element, $('div.message-container'));213messageContainer.setAttribute('aria-hidden', 'true');214messageContainer.appendChild(this.getBannerMessage(item.message));215216// Message Actions217this.messageActionsContainer = append(this.element, $('div.message-actions-container'));218if (item.actions) {219for (const action of item.actions) {220this._register(this.instantiationService.createInstance(Link, this.messageActionsContainer, { ...action, tabIndex: -1 }, {}));221}222}223224// Action225const actionBarContainer = append(this.element, $('div.action-container'));226this.actionBar = this._register(new ActionBar(actionBarContainer));227const label = item.closeLabel ?? localize('closeBanner', "Close Banner");228const closeAction = this._register(new Action('banner.close', label, ThemeIcon.asClassName(widgetClose), true, () => this.close(item)));229this.actionBar.push(closeAction, { icon: true, label: false });230this.actionBar.setFocusable(false);231232this.setVisibility(true);233this.item = item;234}235236toJSON(): object {237return {238type: Parts.BANNER_PART239};240}241}242243registerSingleton(IBannerService, BannerPart, InstantiationType.Eager);244245246// Keybindings247248KeybindingsRegistry.registerCommandAndKeybindingRule({249id: 'workbench.banner.focusBanner',250weight: KeybindingWeight.WorkbenchContrib,251primary: KeyCode.Escape,252when: BannerFocused,253handler: (accessor: ServicesAccessor) => {254const bannerService = accessor.get(IBannerService);255bannerService.focus();256}257});258259KeybindingsRegistry.registerCommandAndKeybindingRule({260id: 'workbench.banner.focusNextAction',261weight: KeybindingWeight.WorkbenchContrib,262primary: KeyCode.RightArrow,263secondary: [KeyCode.DownArrow],264when: BannerFocused,265handler: (accessor: ServicesAccessor) => {266const bannerService = accessor.get(IBannerService);267bannerService.focusNextAction();268}269});270271KeybindingsRegistry.registerCommandAndKeybindingRule({272id: 'workbench.banner.focusPreviousAction',273weight: KeybindingWeight.WorkbenchContrib,274primary: KeyCode.LeftArrow,275secondary: [KeyCode.UpArrow],276when: BannerFocused,277handler: (accessor: ServicesAccessor) => {278const bannerService = accessor.get(IBannerService);279bannerService.focusPreviousAction();280}281});282283284// Actions285286class FocusBannerAction extends Action2 {287288static readonly ID = 'workbench.action.focusBanner';289static readonly LABEL = localize2('focusBanner', "Focus Banner");290291constructor() {292super({293id: FocusBannerAction.ID,294title: FocusBannerAction.LABEL,295category: Categories.View,296f1: true297});298}299300async run(accessor: ServicesAccessor): Promise<void> {301const layoutService = accessor.get(IWorkbenchLayoutService);302layoutService.focusPart(Parts.BANNER_PART);303}304}305306registerAction2(FocusBannerAction);307308309