Path: blob/main/src/vs/platform/actionWidget/browser/actionList.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*--------------------------------------------------------------------------------------------*/4import * as dom from '../../../base/browser/dom.js';5import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js';6import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js';7import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js';8import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';9import { Codicon } from '../../../base/common/codicons.js';10import { ResolvedKeybinding } from '../../../base/common/keybindings.js';11import { Disposable } from '../../../base/common/lifecycle.js';12import { OS } from '../../../base/common/platform.js';13import { ThemeIcon } from '../../../base/common/themables.js';14import './actionWidget.css';15import { localize } from '../../../nls.js';16import { IContextViewService } from '../../contextview/browser/contextView.js';17import { IKeybindingService } from '../../keybinding/common/keybinding.js';18import { defaultListStyles } from '../../theme/browser/defaultStyles.js';19import { asCssVariable } from '../../theme/common/colorRegistry.js';20import { ILayoutService } from '../../layout/browser/layoutService.js';2122export const acceptSelectedActionCommand = 'acceptSelectedCodeAction';23export const previewSelectedActionCommand = 'previewSelectedCodeAction';2425export interface IActionListDelegate<T> {26onHide(didCancel?: boolean): void;27onSelect(action: T, preview?: boolean): void;28onHover?(action: T, cancellationToken: CancellationToken): Promise<{ canPreview: boolean } | void>;29onFocus?(action: T | undefined): void;30}3132export interface IActionListItem<T> {33readonly item?: T;34readonly kind: ActionListItemKind;35readonly group?: { kind?: any; icon?: ThemeIcon; title: string };36readonly disabled?: boolean;37readonly label?: string;38readonly description?: string;39readonly keybinding?: ResolvedKeybinding;40canPreview?: boolean | undefined;41readonly hideIcon?: boolean;42readonly tooltip?: string;43}4445interface IActionMenuTemplateData {46readonly container: HTMLElement;47readonly icon: HTMLElement;48readonly text: HTMLElement;49readonly description?: HTMLElement;50readonly keybinding: KeybindingLabel;51}5253export const enum ActionListItemKind {54Action = 'action',55Header = 'header',56Separator = 'separator'57}5859interface IHeaderTemplateData {60readonly container: HTMLElement;61readonly text: HTMLElement;62}6364class HeaderRenderer<T> implements IListRenderer<IActionListItem<T>, IHeaderTemplateData> {6566get templateId(): string { return ActionListItemKind.Header; }6768renderTemplate(container: HTMLElement): IHeaderTemplateData {69container.classList.add('group-header');7071const text = document.createElement('span');72container.append(text);7374return { container, text };75}7677renderElement(element: IActionListItem<T>, _index: number, templateData: IHeaderTemplateData): void {78templateData.text.textContent = element.group?.title ?? element.label ?? '';79}8081disposeTemplate(_templateData: IHeaderTemplateData): void {82// noop83}84}8586interface ISeparatorTemplateData {87readonly container: HTMLElement;88readonly text: HTMLElement;89}9091class SeparatorRenderer<T> implements IListRenderer<IActionListItem<T>, ISeparatorTemplateData> {9293get templateId(): string { return ActionListItemKind.Separator; }9495renderTemplate(container: HTMLElement): ISeparatorTemplateData {96container.classList.add('separator');9798const text = document.createElement('span');99container.append(text);100101return { container, text };102}103104renderElement(element: IActionListItem<T>, _index: number, templateData: ISeparatorTemplateData): void {105templateData.text.textContent = element.label ?? '';106}107108disposeTemplate(_templateData: ISeparatorTemplateData): void {109// noop110}111}112113class ActionItemRenderer<T> implements IListRenderer<IActionListItem<T>, IActionMenuTemplateData> {114115get templateId(): string { return ActionListItemKind.Action; }116117constructor(118private readonly _supportsPreview: boolean,119@IKeybindingService private readonly _keybindingService: IKeybindingService120) { }121122renderTemplate(container: HTMLElement): IActionMenuTemplateData {123container.classList.add(this.templateId);124125const icon = document.createElement('div');126icon.className = 'icon';127container.append(icon);128129const text = document.createElement('span');130text.className = 'title';131container.append(text);132133const description = document.createElement('span');134description.className = 'description';135container.append(description);136137const keybinding = new KeybindingLabel(container, OS);138139return { container, icon, text, description, keybinding };140}141142renderElement(element: IActionListItem<T>, _index: number, data: IActionMenuTemplateData): void {143if (element.group?.icon) {144data.icon.className = ThemeIcon.asClassName(element.group.icon);145if (element.group.icon.color) {146data.icon.style.color = asCssVariable(element.group.icon.color.id);147}148} else {149data.icon.className = ThemeIcon.asClassName(Codicon.lightBulb);150data.icon.style.color = 'var(--vscode-editorLightBulb-foreground)';151}152153if (!element.item || !element.label) {154return;155}156157dom.setVisibility(!element.hideIcon, data.icon);158159data.text.textContent = stripNewlines(element.label);160161// if there is a keybinding, prioritize over description for now162if (element.keybinding) {163data.description!.textContent = element.keybinding.getLabel();164data.description!.style.display = 'inline';165data.description!.style.letterSpacing = '0.5px';166} else if (element.description) {167data.description!.textContent = stripNewlines(element.description);168data.description!.style.display = 'inline';169} else {170data.description!.textContent = '';171data.description!.style.display = 'none';172}173174const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel();175const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel();176data.container.classList.toggle('option-disabled', element.disabled);177if (element.tooltip) {178data.container.title = element.tooltip;179} else if (element.disabled) {180data.container.title = element.label;181} else if (actionTitle && previewTitle) {182if (this._supportsPreview && element.canPreview) {183data.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);184} else {185data.container.title = localize({ key: 'label', comment: ['placeholder is a keybinding, e.g "F2 to Apply"'] }, "{0} to Apply", actionTitle);186}187} else {188data.container.title = '';189}190}191192disposeTemplate(templateData: IActionMenuTemplateData): void {193templateData.keybinding.dispose();194}195}196197class AcceptSelectedEvent extends UIEvent {198constructor() { super('acceptSelectedAction'); }199}200201class PreviewSelectedEvent extends UIEvent {202constructor() { super('previewSelectedAction'); }203}204205function getKeyboardNavigationLabel<T>(item: IActionListItem<T>): string | undefined {206// Filter out header vs. action vs. separator207if (item.kind === 'action') {208return item.label;209}210return undefined;211}212213export class ActionList<T> extends Disposable {214215public readonly domNode: HTMLElement;216217private readonly _list: List<IActionListItem<T>>;218219private readonly _actionLineHeight = 24;220private readonly _headerLineHeight = 26;221private readonly _separatorLineHeight = 8;222223private readonly _allMenuItems: readonly IActionListItem<T>[];224225private readonly cts = this._register(new CancellationTokenSource());226227constructor(228user: string,229preview: boolean,230items: readonly IActionListItem<T>[],231private readonly _delegate: IActionListDelegate<T>,232accessibilityProvider: Partial<IListAccessibilityProvider<IActionListItem<T>>> | undefined,233@IContextViewService private readonly _contextViewService: IContextViewService,234@IKeybindingService private readonly _keybindingService: IKeybindingService,235@ILayoutService private readonly _layoutService: ILayoutService,236) {237super();238this.domNode = document.createElement('div');239this.domNode.classList.add('actionList');240const virtualDelegate: IListVirtualDelegate<IActionListItem<T>> = {241getHeight: element => {242switch (element.kind) {243case ActionListItemKind.Header:244return this._headerLineHeight;245case ActionListItemKind.Separator:246return this._separatorLineHeight;247default:248return this._actionLineHeight;249}250},251getTemplateId: element => element.kind252};253254255this._list = this._register(new List(user, this.domNode, virtualDelegate, [256new ActionItemRenderer<IActionListItem<T>>(preview, this._keybindingService),257new HeaderRenderer(),258new SeparatorRenderer(),259], {260keyboardSupport: false,261typeNavigationEnabled: true,262keyboardNavigationLabelProvider: { getKeyboardNavigationLabel },263accessibilityProvider: {264getAriaLabel: element => {265if (element.kind === ActionListItemKind.Action) {266let label = element.label ? stripNewlines(element?.label) : '';267if (element.disabled) {268label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled);269}270return label;271}272return null;273},274getWidgetAriaLabel: () => localize({ key: 'customQuickFixWidget', comment: [`An action widget option`] }, "Action Widget"),275getRole: (e) => {276switch (e.kind) {277case ActionListItemKind.Action:278return 'option';279case ActionListItemKind.Separator:280return 'separator';281default:282return 'separator';283}284},285getWidgetRole: () => 'listbox',286...accessibilityProvider287},288}));289290this._list.style(defaultListStyles);291292this._register(this._list.onMouseClick(e => this.onListClick(e)));293this._register(this._list.onMouseOver(e => this.onListHover(e)));294this._register(this._list.onDidChangeFocus(() => this.onFocus()));295this._register(this._list.onDidChangeSelection(e => this.onListSelection(e)));296297this._allMenuItems = items;298this._list.splice(0, this._list.length, this._allMenuItems);299300if (this._list.length) {301this.focusNext();302}303}304305private focusCondition(element: IActionListItem<unknown>): boolean {306return !element.disabled && element.kind === ActionListItemKind.Action;307}308309hide(didCancel?: boolean): void {310this._delegate.onHide(didCancel);311this.cts.cancel();312this._contextViewService.hideContextView();313}314315layout(minWidth: number): number {316// Updating list height, depending on how many separators and headers there are.317const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length;318const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length;319const itemsHeight = this._allMenuItems.length * this._actionLineHeight;320const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight;321const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight;322this._list.layout(heightWithSeparators);323let maxWidth = minWidth;324325if (this._allMenuItems.length >= 50) {326maxWidth = 380;327} else {328// For finding width dynamically (not using resize observer)329const itemWidths: number[] = this._allMenuItems.map((_, index): number => {330const element = this.domNode.ownerDocument.getElementById(this._list.getElementID(index));331if (element) {332element.style.width = 'auto';333const width = element.getBoundingClientRect().width;334element.style.width = '';335return width;336}337return 0;338});339340// resize observer - can be used in the future since list widget supports dynamic height but not width341maxWidth = Math.max(...itemWidths, minWidth);342}343344const maxVhPrecentage = 0.7;345const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage);346this._list.layout(height, maxWidth);347348this.domNode.style.height = `${height}px`;349350this._list.domFocus();351return maxWidth;352}353354focusPrevious() {355this._list.focusPrevious(1, true, undefined, this.focusCondition);356}357358focusNext() {359this._list.focusNext(1, true, undefined, this.focusCondition);360}361362acceptSelected(preview?: boolean) {363const focused = this._list.getFocus();364if (focused.length === 0) {365return;366}367368const focusIndex = focused[0];369const element = this._list.element(focusIndex);370if (!this.focusCondition(element)) {371return;372}373374const event = preview ? new PreviewSelectedEvent() : new AcceptSelectedEvent();375this._list.setSelection([focusIndex], event);376}377378private onListSelection(e: IListEvent<IActionListItem<T>>): void {379if (!e.elements.length) {380return;381}382383const element = e.elements[0];384if (element.item && this.focusCondition(element)) {385this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent);386} else {387this._list.setSelection([]);388}389}390391private onFocus() {392const focused = this._list.getFocus();393if (focused.length === 0) {394return;395}396const focusIndex = focused[0];397const element = this._list.element(focusIndex);398this._delegate.onFocus?.(element.item);399}400401private async onListHover(e: IListMouseEvent<IActionListItem<T>>) {402const element = e.element;403if (element && element.item && this.focusCondition(element)) {404if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) {405const result = await this._delegate.onHover(element.item, this.cts.token);406element.canPreview = result ? result.canPreview : undefined;407}408if (e.index) {409this._list.splice(e.index, 1, [element]);410}411}412413this._list.setFocus(typeof e.index === 'number' ? [e.index] : []);414}415416private onListClick(e: IListMouseEvent<IActionListItem<T>>): void {417if (e.element && this.focusCondition(e.element)) {418this._list.setFocus([]);419}420}421}422423function stripNewlines(str: string): string {424return str.replace(/\r\n|\r|\n/g, ' ');425}426427428