Path: blob/main/src/vs/platform/actionWidget/browser/actionList.ts
5240 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*--------------------------------------------------------------------------------------------*/4import * as dom from '../../../base/browser/dom.js';5import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js';6import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js';7import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js';8import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js';9import { IAction } from '../../../base/common/actions.js';10import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';11import { Codicon } from '../../../base/common/codicons.js';12import { ResolvedKeybinding } from '../../../base/common/keybindings.js';13import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';14import { OS } from '../../../base/common/platform.js';15import { ThemeIcon } from '../../../base/common/themables.js';16import './actionWidget.css';17import { localize } from '../../../nls.js';18import { IContextViewService } from '../../contextview/browser/contextView.js';19import { IKeybindingService } from '../../keybinding/common/keybinding.js';20import { defaultListStyles } from '../../theme/browser/defaultStyles.js';21import { asCssVariable } from '../../theme/common/colorRegistry.js';22import { ILayoutService } from '../../layout/browser/layoutService.js';23import { IHoverService } from '../../hover/browser/hover.js';24import { MarkdownString } from '../../../base/common/htmlContent.js';25import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js';26import { IHoverPositionOptions, IHoverWidget } from '../../../base/browser/ui/hover/hover.js';2728export const acceptSelectedActionCommand = 'acceptSelectedCodeAction';29export const previewSelectedActionCommand = 'previewSelectedCodeAction';3031export interface IActionListDelegate<T> {32onHide(didCancel?: boolean): void;33onSelect(action: T, preview?: boolean): void;34onHover?(action: T, cancellationToken: CancellationToken): Promise<{ canPreview: boolean } | void>;35onFocus?(action: T | undefined): void;36}3738/**39* Optional hover configuration shown when focusing/hovering over an action list item.40*/41export interface IActionListItemHover {42/**43* Content to display in the hover.44*/45readonly content?: string;4647readonly position?: IHoverPositionOptions;48}4950export interface IActionListItem<T> {51readonly item?: T;52readonly kind: ActionListItemKind;53readonly group?: { kind?: unknown; icon?: ThemeIcon; title: string };54readonly disabled?: boolean;55readonly label?: string;56readonly description?: string;57/**58* Optional hover configuration shown when focusing/hovering over the item.59*/60readonly hover?: IActionListItemHover;61readonly keybinding?: ResolvedKeybinding;62canPreview?: boolean | undefined;63readonly hideIcon?: boolean;64readonly tooltip?: string;65/**66* Optional toolbar actions shown when the item is focused or hovered.67*/68readonly toolbarActions?: IAction[];69}7071interface IActionMenuTemplateData {72readonly container: HTMLElement;73readonly icon: HTMLElement;74readonly text: HTMLElement;75readonly description?: HTMLElement;76readonly keybinding: KeybindingLabel;77readonly toolbar: HTMLElement;78readonly elementDisposables: DisposableStore;79}8081export const enum ActionListItemKind {82Action = 'action',83Header = 'header',84Separator = 'separator'85}8687interface IHeaderTemplateData {88readonly container: HTMLElement;89readonly text: HTMLElement;90}9192class HeaderRenderer<T> implements IListRenderer<IActionListItem<T>, IHeaderTemplateData> {9394get templateId(): string { return ActionListItemKind.Header; }9596renderTemplate(container: HTMLElement): IHeaderTemplateData {97container.classList.add('group-header');9899const text = document.createElement('span');100container.append(text);101102return { container, text };103}104105renderElement(element: IActionListItem<T>, _index: number, templateData: IHeaderTemplateData): void {106templateData.text.textContent = element.group?.title ?? element.label ?? '';107}108109disposeTemplate(_templateData: IHeaderTemplateData): void {110// noop111}112}113114interface ISeparatorTemplateData {115readonly container: HTMLElement;116readonly text: HTMLElement;117}118119class SeparatorRenderer<T> implements IListRenderer<IActionListItem<T>, ISeparatorTemplateData> {120121get templateId(): string { return ActionListItemKind.Separator; }122123renderTemplate(container: HTMLElement): ISeparatorTemplateData {124container.classList.add('separator');125126const text = document.createElement('span');127container.append(text);128129return { container, text };130}131132renderElement(element: IActionListItem<T>, _index: number, templateData: ISeparatorTemplateData): void {133templateData.text.textContent = element.label ?? '';134}135136disposeTemplate(_templateData: ISeparatorTemplateData): void {137// noop138}139}140141class ActionItemRenderer<T> implements IListRenderer<IActionListItem<T>, IActionMenuTemplateData> {142143get templateId(): string { return ActionListItemKind.Action; }144145constructor(146private readonly _supportsPreview: boolean,147@IKeybindingService private readonly _keybindingService: IKeybindingService148) { }149150renderTemplate(container: HTMLElement): IActionMenuTemplateData {151container.classList.add(this.templateId);152153const icon = document.createElement('div');154icon.className = 'icon';155container.append(icon);156157const text = document.createElement('span');158text.className = 'title';159container.append(text);160161const description = document.createElement('span');162description.className = 'description';163container.append(description);164165const keybinding = new KeybindingLabel(container, OS);166167const toolbar = document.createElement('div');168toolbar.className = 'action-list-item-toolbar';169container.append(toolbar);170171const elementDisposables = new DisposableStore();172173return { container, icon, text, description, keybinding, toolbar, elementDisposables };174}175176renderElement(element: IActionListItem<T>, _index: number, data: IActionMenuTemplateData): void {177// Clear previous element disposables178data.elementDisposables.clear();179180if (element.group?.icon) {181data.icon.className = ThemeIcon.asClassName(element.group.icon);182if (element.group.icon.color) {183data.icon.style.color = asCssVariable(element.group.icon.color.id);184}185} else {186data.icon.className = ThemeIcon.asClassName(Codicon.lightBulb);187data.icon.style.color = 'var(--vscode-editorLightBulb-foreground)';188}189190if (!element.item || !element.label) {191return;192}193194dom.setVisibility(!element.hideIcon, data.icon);195196data.text.textContent = stripNewlines(element.label);197198// if there is a keybinding, prioritize over description for now199if (element.keybinding) {200data.description!.textContent = element.keybinding.getLabel();201data.description!.style.display = 'inline';202data.description!.style.letterSpacing = '0.5px';203} else if (element.description) {204data.description!.textContent = stripNewlines(element.description);205data.description!.style.display = 'inline';206} else {207data.description!.textContent = '';208data.description!.style.display = 'none';209}210211const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel();212const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel();213data.container.classList.toggle('option-disabled', element.disabled);214if (element.hover !== undefined) {215// Don't show tooltip when hover content is configured - the rich hover will show instead216data.container.title = '';217} else if (element.tooltip) {218data.container.title = element.tooltip;219} else if (element.disabled) {220data.container.title = element.label;221} else if (actionTitle && previewTitle) {222if (this._supportsPreview && element.canPreview) {223data.container.title = localize({ key: 'label-preview', comment: ['placeholders are keybindings, e.g "F2 to Apply, Shift+F2 to Preview"'] }, "{0} to Apply, {1} to Preview", actionTitle, previewTitle);224} else {225data.container.title = localize({ key: 'label', comment: ['placeholder is a keybinding, e.g "F2 to Apply"'] }, "{0} to Apply", actionTitle);226}227} else {228data.container.title = '';229}230231// Clear and render toolbar actions232dom.clearNode(data.toolbar);233data.container.classList.toggle('has-toolbar', !!element.toolbarActions?.length);234if (element.toolbarActions?.length) {235const actionBar = new ActionBar(data.toolbar);236data.elementDisposables.add(actionBar);237actionBar.push(element.toolbarActions, { icon: true, label: false });238}239}240241disposeTemplate(templateData: IActionMenuTemplateData): void {242templateData.keybinding.dispose();243templateData.elementDisposables.dispose();244}245}246247class AcceptSelectedEvent extends UIEvent {248constructor() { super('acceptSelectedAction'); }249}250251class PreviewSelectedEvent extends UIEvent {252constructor() { super('previewSelectedAction'); }253}254255function getKeyboardNavigationLabel<T>(item: IActionListItem<T>): string | undefined {256// Filter out header vs. action vs. separator257if (item.kind === 'action') {258return item.label;259}260return undefined;261}262263export class ActionList<T> extends Disposable {264265public readonly domNode: HTMLElement;266267private readonly _list: List<IActionListItem<T>>;268269private readonly _actionLineHeight = 28;270private readonly _headerLineHeight = 28;271private readonly _separatorLineHeight = 8;272273private readonly _allMenuItems: readonly IActionListItem<T>[];274275private readonly cts = this._register(new CancellationTokenSource());276277private _hover = this._register(new MutableDisposable<IHoverWidget>());278279constructor(280user: string,281preview: boolean,282items: readonly IActionListItem<T>[],283private readonly _delegate: IActionListDelegate<T>,284accessibilityProvider: Partial<IListAccessibilityProvider<IActionListItem<T>>> | undefined,285@IContextViewService private readonly _contextViewService: IContextViewService,286@IKeybindingService private readonly _keybindingService: IKeybindingService,287@ILayoutService private readonly _layoutService: ILayoutService,288@IHoverService private readonly _hoverService: IHoverService,289) {290super();291this.domNode = document.createElement('div');292this.domNode.classList.add('actionList');293const virtualDelegate: IListVirtualDelegate<IActionListItem<T>> = {294getHeight: element => {295switch (element.kind) {296case ActionListItemKind.Header:297return this._headerLineHeight;298case ActionListItemKind.Separator:299return this._separatorLineHeight;300default:301return this._actionLineHeight;302}303},304getTemplateId: element => element.kind305};306307308this._list = this._register(new List(user, this.domNode, virtualDelegate, [309new ActionItemRenderer<IActionListItem<T>>(preview, this._keybindingService),310new HeaderRenderer(),311new SeparatorRenderer(),312], {313keyboardSupport: false,314typeNavigationEnabled: true,315keyboardNavigationLabelProvider: { getKeyboardNavigationLabel },316accessibilityProvider: {317getAriaLabel: element => {318if (element.kind === ActionListItemKind.Action) {319let label = element.label ? stripNewlines(element?.label) : '';320if (element.description) {321label = label + ', ' + stripNewlines(element.description);322}323if (element.disabled) {324label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled);325}326return label;327}328return null;329},330getWidgetAriaLabel: () => localize({ key: 'customQuickFixWidget', comment: [`An action widget option`] }, "Action Widget"),331getRole: (e) => {332switch (e.kind) {333case ActionListItemKind.Action:334return 'option';335case ActionListItemKind.Separator:336return 'separator';337default:338return 'separator';339}340},341getWidgetRole: () => 'listbox',342...accessibilityProvider343},344}));345346this._list.style(defaultListStyles);347348this._register(this._list.onMouseClick(e => this.onListClick(e)));349this._register(this._list.onMouseOver(e => this.onListHover(e)));350this._register(this._list.onDidChangeFocus(() => this.onFocus()));351this._register(this._list.onDidChangeSelection(e => this.onListSelection(e)));352353this._allMenuItems = items;354this._list.splice(0, this._list.length, this._allMenuItems);355356if (this._list.length) {357this.focusNext();358}359}360361private focusCondition(element: IActionListItem<unknown>): boolean {362return !element.disabled && element.kind === ActionListItemKind.Action;363}364365hide(didCancel?: boolean): void {366this._delegate.onHide(didCancel);367this.cts.cancel();368this._hover.clear();369this._contextViewService.hideContextView();370}371372layout(minWidth: number): number {373// Updating list height, depending on how many separators and headers there are.374const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length;375const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length;376const itemsHeight = this._allMenuItems.length * this._actionLineHeight;377const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight;378const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight;379this._list.layout(heightWithSeparators);380let maxWidth = minWidth;381382if (this._allMenuItems.length >= 50) {383maxWidth = 380;384} else {385// For finding width dynamically (not using resize observer)386const itemWidths: number[] = this._allMenuItems.map((_, index): number => {387const element = this._getRowElement(index);388if (element) {389element.style.width = 'auto';390const width = element.getBoundingClientRect().width;391element.style.width = '';392return width;393}394return 0;395});396397// resize observer - can be used in the future since list widget supports dynamic height but not width398maxWidth = Math.max(...itemWidths, minWidth);399}400401const maxVhPrecentage = 0.7;402const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage);403this._list.layout(height, maxWidth);404405this.domNode.style.height = `${height}px`;406407this._list.domFocus();408return maxWidth;409}410411focusPrevious() {412this._list.focusPrevious(1, true, undefined, this.focusCondition);413}414415focusNext() {416this._list.focusNext(1, true, undefined, this.focusCondition);417}418419acceptSelected(preview?: boolean) {420const focused = this._list.getFocus();421if (focused.length === 0) {422return;423}424425const focusIndex = focused[0];426const element = this._list.element(focusIndex);427if (!this.focusCondition(element)) {428return;429}430431const event = preview ? new PreviewSelectedEvent() : new AcceptSelectedEvent();432this._list.setSelection([focusIndex], event);433}434435private onListSelection(e: IListEvent<IActionListItem<T>>): void {436if (!e.elements.length) {437return;438}439440const element = e.elements[0];441if (element.item && this.focusCondition(element)) {442this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent);443} else {444this._list.setSelection([]);445}446}447448private onFocus() {449const focused = this._list.getFocus();450if (focused.length === 0) {451return;452}453const focusIndex = focused[0];454const element = this._list.element(focusIndex);455this._delegate.onFocus?.(element.item);456457// Show hover on focus change458this._showHoverForElement(element, focusIndex);459}460461private _getRowElement(index: number): HTMLElement | null {462// eslint-disable-next-line no-restricted-syntax463return this.domNode.ownerDocument.getElementById(this._list.getElementID(index));464}465466private _showHoverForElement(element: IActionListItem<T>, index: number): void {467let newHover: IHoverWidget | undefined;468469// Show hover if the element has hover content470if (element.hover?.content && this.focusCondition(element)) {471// The List widget separates data models from DOM elements, so we need to472// look up the actual DOM node to use as the hover target.473const rowElement = this._getRowElement(index);474if (rowElement) {475const markdown = element.hover.content ? new MarkdownString(element.hover.content) : undefined;476newHover = this._hoverService.showDelayedHover({477content: markdown ?? '',478target: rowElement,479additionalClasses: ['action-widget-hover'],480position: {481hoverPosition: HoverPosition.LEFT,482forcePosition: false,483...element.hover.position,484},485appearance: {486showPointer: true,487},488}, { groupId: `actionListHover` });489}490}491492this._hover.value = newHover;493}494495private async onListHover(e: IListMouseEvent<IActionListItem<T>>) {496const element = e.element;497498if (element && element.item && this.focusCondition(element)) {499// Check if the hover target is inside a toolbar - if so, skip the splice500// to avoid re-rendering which would destroy the toolbar mid-hover501const isHoveringToolbar = dom.isHTMLElement(e.browserEvent.target) && e.browserEvent.target.closest('.action-list-item-toolbar') !== null;502if (isHoveringToolbar) {503this._list.setFocus([]);504return;505}506507if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) {508const result = await this._delegate.onHover(element.item, this.cts.token);509element.canPreview = result ? result.canPreview : undefined;510}511if (e.index) {512this._list.splice(e.index, 1, [element]);513}514515this._list.setFocus(typeof e.index === 'number' ? [e.index] : []);516}517}518519private onListClick(e: IListMouseEvent<IActionListItem<T>>): void {520if (e.element && this.focusCondition(e.element)) {521this._list.setFocus([]);522}523}524}525526function stripNewlines(str: string): string {527return str.replace(/\r\n|\r|\n/g, ' ');528}529530531