/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as cssJs from '../../../base/browser/cssValue.js'; import * as dom from '../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { AriaRole } from '../../../base/browser/ui/aria/aria.js'; import type { IHoverWidget, IManagedHoverTooltipMarkdownString } from '../../../base/browser/ui/hover/hover.js'; import { IHoverDelegate } from '../../../base/browser/ui/hover/hoverDelegate.js'; import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; import { IIconLabelValueOptions, IconLabel } from '../../../base/browser/ui/iconLabel/iconLabel.js'; import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, IListStyles } from '../../../base/browser/ui/list/listWidget.js'; import { Checkbox } from '../../../base/browser/ui/toggle/toggle.js'; import { RenderIndentGuides } from '../../../base/browser/ui/tree/abstractTree.js'; import { IObjectTreeElement, ITreeNode, ITreeRenderer, TreeVisibility } from '../../../base/browser/ui/tree/tree.js'; import { equals } from '../../../base/common/arrays.js'; import { ThrottledDelayer } from '../../../base/common/async.js'; import { compareAnything } from '../../../base/common/comparers.js'; import { memoize } from '../../../base/common/decorators.js'; import { isCancellationError } from '../../../base/common/errors.js'; import { Emitter, Event, EventBufferer, IValueWithChangeEvent } from '../../../base/common/event.js'; import { IMatch } from '../../../base/common/filters.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; import { IParsedLabelWithIcons, getCodiconAriaLabel, matchesFuzzyIconAware, parseLabelWithIcons } from '../../../base/common/iconLabels.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { Lazy } from '../../../base/common/lazy.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { observableValue, observableValueOpts, transaction } from '../../../base/common/observable.js'; import { OS } from '../../../base/common/platform.js'; import { escape, ltrim } from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IAccessibilityService } from '../../accessibility/common/accessibility.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { WorkbenchObjectTree } from '../../list/browser/listService.js'; import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { isDark } from '../../theme/common/theme.js'; import { IThemeService } from '../../theme/common/themeService.js'; import { IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, QuickPickFocus, QuickPickItem } from '../common/quickInput.js'; import { quickInputButtonToAction } from './quickInputUtils.js'; const $ = dom.$; interface IQuickInputItemLazyParts { readonly saneLabel: string; readonly saneSortLabel: string; readonly saneAriaLabel: string; } interface IQuickPickElement extends IQuickInputItemLazyParts { readonly hasCheckbox: boolean; readonly index: number; readonly item?: IQuickPickItem; readonly saneDescription?: string; readonly saneDetail?: string; readonly saneTooltip?: string | IMarkdownString | HTMLElement; hidden: boolean; element?: HTMLElement; labelHighlights?: IMatch[]; descriptionHighlights?: IMatch[]; detailHighlights?: IMatch[]; separator?: IQuickPickSeparator; } interface IQuickInputItemTemplateData { entry: HTMLDivElement; checkbox: MutableDisposable; icon: HTMLDivElement; outerLabel: HTMLElement; label: IconLabel; keybinding: KeybindingLabel; detail: IconLabel; separator: HTMLDivElement; actionBar: ActionBar; element: IQuickPickElement; toDisposeElement: DisposableStore; toDisposeTemplate: DisposableStore; } class BaseQuickPickItemElement implements IQuickPickElement { private readonly _init: Lazy; constructor( readonly index: number, readonly hasCheckbox: boolean, mainItem: QuickPickItem ) { this._init = new Lazy(() => { const saneLabel = mainItem.label ?? ''; const saneSortLabel = parseLabelWithIcons(saneLabel).text.trim(); const saneAriaLabel = mainItem.ariaLabel || [saneLabel, this.saneDescription, this.saneDetail] .map(s => getCodiconAriaLabel(s)) .filter(s => !!s) .join(', '); return { saneLabel, saneSortLabel, saneAriaLabel }; }); this._saneDescription = mainItem.description; this._saneTooltip = mainItem.tooltip; } // #region Lazy Getters get saneLabel() { return this._init.value.saneLabel; } get saneSortLabel() { return this._init.value.saneSortLabel; } get saneAriaLabel() { return this._init.value.saneAriaLabel; } // #endregion // #region Getters and Setters private _element?: HTMLElement; get element() { return this._element; } set element(value: HTMLElement | undefined) { this._element = value; } private _hidden = false; get hidden() { return this._hidden; } set hidden(value: boolean) { this._hidden = value; } private _saneDescription?: string; get saneDescription() { return this._saneDescription; } set saneDescription(value: string | undefined) { this._saneDescription = value; } protected _saneDetail?: string; get saneDetail() { return this._saneDetail; } set saneDetail(value: string | undefined) { this._saneDetail = value; } private _saneTooltip?: string | IMarkdownString | HTMLElement; get saneTooltip() { return this._saneTooltip; } set saneTooltip(value: string | IMarkdownString | HTMLElement | undefined) { this._saneTooltip = value; } protected _labelHighlights?: IMatch[]; get labelHighlights() { return this._labelHighlights; } set labelHighlights(value: IMatch[] | undefined) { this._labelHighlights = value; } protected _descriptionHighlights?: IMatch[]; get descriptionHighlights() { return this._descriptionHighlights; } set descriptionHighlights(value: IMatch[] | undefined) { this._descriptionHighlights = value; } protected _detailHighlights?: IMatch[]; get detailHighlights() { return this._detailHighlights; } set detailHighlights(value: IMatch[] | undefined) { this._detailHighlights = value; } } class QuickPickItemElement extends BaseQuickPickItemElement { readonly onChecked: Event; constructor( index: number, hasCheckbox: boolean, readonly fireButtonTriggered: (event: IQuickPickItemButtonEvent) => void, private _onChecked: Emitter<{ element: IQuickPickElement; checked: boolean }>, readonly item: IQuickPickItem, private _separator: IQuickPickSeparator | undefined, ) { super(index, hasCheckbox, item); this.onChecked = hasCheckbox ? Event.map(Event.filter<{ element: IQuickPickElement; checked: boolean }>(this._onChecked.event, e => e.element === this), e => e.checked) : Event.None; this._saneDetail = item.detail; this._labelHighlights = item.highlights?.label; this._descriptionHighlights = item.highlights?.description; this._detailHighlights = item.highlights?.detail; } get separator() { return this._separator; } set separator(value: IQuickPickSeparator | undefined) { this._separator = value; } private _checked = false; get checked() { return this._checked; } set checked(value: boolean) { if (value !== this._checked) { this._checked = value; this._onChecked.fire({ element: this, checked: value }); } } get checkboxDisabled() { return !!this.item.disabled; } } enum QuickPickSeparatorFocusReason { /** * No item is hovered or active */ NONE = 0, /** * Some item within this section is hovered */ MOUSE_HOVER = 1, /** * Some item within this section is active */ ACTIVE_ITEM = 2 } class QuickPickSeparatorElement extends BaseQuickPickItemElement { children = new Array(); /** * If this item is >0, it means that there is some item in the list that is either: * * hovered over * * active */ focusInsideSeparator = QuickPickSeparatorFocusReason.NONE; constructor( index: number, readonly fireSeparatorButtonTriggered: (event: IQuickPickSeparatorButtonEvent) => void, readonly separator: IQuickPickSeparator, ) { super(index, false, separator); } } class QuickInputItemDelegate implements IListVirtualDelegate { getHeight(element: IQuickPickElement): number { if (element instanceof QuickPickSeparatorElement) { return 30; } return element.saneDetail ? 44 : 22; } getTemplateId(element: IQuickPickElement): string { if (element instanceof QuickPickItemElement) { return QuickPickItemElementRenderer.ID; } else { return QuickPickSeparatorElementRenderer.ID; } } } class QuickInputAccessibilityProvider implements IListAccessibilityProvider { getWidgetAriaLabel(): string { return localize('quickInput', "Quick Input"); } getAriaLabel(element: IQuickPickElement): string | null { return element.separator?.label ? `${element.saneAriaLabel}, ${element.separator.label}` : element.saneAriaLabel; } getWidgetRole(): AriaRole { return 'listbox'; } getRole(element: IQuickPickElement) { return element.hasCheckbox ? 'checkbox' : 'option'; } isChecked(element: IQuickPickElement): IValueWithChangeEvent | undefined { if (!element.hasCheckbox || !(element instanceof QuickPickItemElement)) { return undefined; } return { get value() { return element.checked; }, onDidChange: e => element.onChecked(() => e()), }; } } abstract class BaseQuickInputListRenderer implements ITreeRenderer { abstract templateId: string; constructor( private readonly hoverDelegate: IHoverDelegate | undefined, ) { } // TODO: only do the common stuff here and have a subclass handle their specific stuff renderTemplate(container: HTMLElement): IQuickInputItemTemplateData { const data: IQuickInputItemTemplateData = Object.create(null); data.toDisposeElement = new DisposableStore(); data.toDisposeTemplate = new DisposableStore(); data.entry = dom.append(container, $('.quick-input-list-entry')); // Checkbox const label = dom.append(data.entry, $('label.quick-input-list-label')); data.outerLabel = label; data.checkbox = data.toDisposeTemplate.add(new MutableDisposable()); data.toDisposeTemplate.add(dom.addStandardDisposableListener(label, dom.EventType.CLICK, e => { // `label` elements with role=checkboxes don't automatically toggle them like normal elements if (data.checkbox.value && !e.defaultPrevented) { data.checkbox.value.checked = !data.checkbox.value.checked; } })); // Rows const rows = dom.append(label, $('.quick-input-list-rows')); const row1 = dom.append(rows, $('.quick-input-list-row')); const row2 = dom.append(rows, $('.quick-input-list-row')); // Label data.label = new IconLabel(row1, { supportHighlights: true, supportDescriptionHighlights: true, supportIcons: true, hoverDelegate: this.hoverDelegate }); data.toDisposeTemplate.add(data.label); data.icon = dom.prepend(data.label.element, $('.quick-input-list-icon')); // Keybinding const keybindingContainer = dom.append(row1, $('.quick-input-list-entry-keybinding')); data.keybinding = new KeybindingLabel(keybindingContainer, OS); data.toDisposeTemplate.add(data.keybinding); // Detail const detailContainer = dom.append(row2, $('.quick-input-list-label-meta')); data.detail = new IconLabel(detailContainer, { supportHighlights: true, supportIcons: true, hoverDelegate: this.hoverDelegate }); data.toDisposeTemplate.add(data.detail); // Separator data.separator = dom.append(data.entry, $('.quick-input-list-separator')); // Actions data.actionBar = new ActionBar(data.entry, this.hoverDelegate ? { hoverDelegate: this.hoverDelegate } : undefined); data.actionBar.domNode.classList.add('quick-input-list-entry-action-bar'); data.toDisposeTemplate.add(data.actionBar); return data; } disposeTemplate(data: IQuickInputItemTemplateData): void { data.toDisposeElement.dispose(); data.toDisposeTemplate.dispose(); } disposeElement(_element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { data.toDisposeElement.clear(); data.actionBar.clear(); } // TODO: only do the common stuff here and have a subclass handle their specific stuff abstract renderElement(node: ITreeNode, index: number, data: IQuickInputItemTemplateData): void; } class QuickPickItemElementRenderer extends BaseQuickInputListRenderer { static readonly ID = 'quickpickitem'; // Follow what we do in the separator renderer private readonly _itemsWithSeparatorsFrequency = new Map(); constructor( hoverDelegate: IHoverDelegate | undefined, @IThemeService private readonly themeService: IThemeService, ) { super(hoverDelegate); } get templateId() { return QuickPickItemElementRenderer.ID; } private ensureCheckbox(element: QuickPickItemElement, data: IQuickInputItemTemplateData) { if (!element.hasCheckbox) { data.checkbox.value?.domNode.remove(); data.checkbox.clear(); return; } let checkbox = data.checkbox.value; if (!checkbox) { checkbox = new Checkbox(element.saneLabel, element.checked, { ...defaultCheckboxStyles, size: 15 }); data.checkbox.value = checkbox; data.outerLabel.prepend(checkbox.domNode); } else { checkbox.setTitle(element.saneLabel); } if (element.checkboxDisabled) { checkbox.disable(); } else { checkbox.enable(); } checkbox.checked = element.checked; data.toDisposeElement.add(element.onChecked(checked => checkbox.checked = checked)); data.toDisposeElement.add(checkbox.onChange(() => element.checked = checkbox.checked)); } renderElement(node: ITreeNode, index: number, data: IQuickInputItemTemplateData): void { const element = node.element; data.element = element; element.element = data.entry ?? undefined; const mainItem: IQuickPickItem = element.item; element.element.classList.toggle('indented', Boolean(mainItem.indented)); element.element.classList.toggle('not-pickable', element.item.pickable === false); this.ensureCheckbox(element, data); const { labelHighlights, descriptionHighlights, detailHighlights } = element; // Icon if (mainItem.iconPath) { const icon = isDark(this.themeService.getColorTheme().type) ? mainItem.iconPath.dark : (mainItem.iconPath.light ?? mainItem.iconPath.dark); const iconUrl = URI.revive(icon); data.icon.className = 'quick-input-list-icon'; data.icon.style.backgroundImage = cssJs.asCSSUrl(iconUrl); } else { data.icon.style.backgroundImage = ''; data.icon.className = mainItem.iconClass ? `quick-input-list-icon ${mainItem.iconClass}` : ''; } // Label let descriptionTitle: IManagedHoverTooltipMarkdownString | undefined; // if we have a tooltip, that will be the hover, // with the saneDescription as fallback if it // is defined if (!element.saneTooltip && element.saneDescription) { descriptionTitle = { markdown: { value: escape(element.saneDescription), supportThemeIcons: true }, markdownNotSupportedFallback: element.saneDescription }; } const options: IIconLabelValueOptions = { matches: labelHighlights || [], // If we have a tooltip, we want that to be shown and not any other hover descriptionTitle, descriptionMatches: descriptionHighlights || [], labelEscapeNewLines: true }; options.extraClasses = mainItem.iconClasses; options.italic = mainItem.italic; options.strikethrough = mainItem.strikethrough; data.entry.classList.remove('quick-input-list-separator-as-item'); data.label.setLabel(element.saneLabel, element.saneDescription, options); // Keybinding data.keybinding.set(mainItem.keybinding); // Detail if (element.saneDetail) { let title: IManagedHoverTooltipMarkdownString | undefined; // If we have a tooltip, we want that to be shown and not any other hover if (!element.saneTooltip) { title = { markdown: { value: escape(element.saneDetail), supportThemeIcons: true }, markdownNotSupportedFallback: element.saneDetail }; } data.detail.element.style.display = ''; data.detail.setLabel(element.saneDetail, undefined, { matches: detailHighlights, title, labelEscapeNewLines: true }); } else { data.detail.element.style.display = 'none'; } // Separator if (element.separator?.label) { data.separator.textContent = element.separator.label; data.separator.style.display = ''; this.addItemWithSeparator(element); } else { data.separator.style.display = 'none'; } data.entry.classList.toggle('quick-input-list-separator-border', !!element.separator); // Actions const buttons = mainItem.buttons; if (buttons && buttons.length) { data.actionBar.push(buttons.map((button, index) => quickInputButtonToAction( button, `id-${index}`, () => element.fireButtonTriggered({ button, item: element.item }) )), { icon: true, label: false }); data.entry.classList.add('has-actions'); } else { data.entry.classList.remove('has-actions'); } } override disposeElement(element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { this.removeItemWithSeparator(element.element); super.disposeElement(element, _index, data); } isItemWithSeparatorVisible(item: QuickPickItemElement): boolean { return this._itemsWithSeparatorsFrequency.has(item); } private addItemWithSeparator(item: QuickPickItemElement): void { this._itemsWithSeparatorsFrequency.set(item, (this._itemsWithSeparatorsFrequency.get(item) || 0) + 1); } private removeItemWithSeparator(item: QuickPickItemElement): void { const frequency = this._itemsWithSeparatorsFrequency.get(item) || 0; if (frequency > 1) { this._itemsWithSeparatorsFrequency.set(item, frequency - 1); } else { this._itemsWithSeparatorsFrequency.delete(item); } } } class QuickPickSeparatorElementRenderer extends BaseQuickInputListRenderer { static readonly ID = 'quickpickseparator'; // This is a frequency map because sticky scroll re-uses the same renderer to render a second // instance of the same separator. private readonly _visibleSeparatorsFrequency = new Map(); get templateId() { return QuickPickSeparatorElementRenderer.ID; } get visibleSeparators(): QuickPickSeparatorElement[] { return [...this._visibleSeparatorsFrequency.keys()]; } isSeparatorVisible(separator: QuickPickSeparatorElement): boolean { return this._visibleSeparatorsFrequency.has(separator); } override renderElement(node: ITreeNode, index: number, data: IQuickInputItemTemplateData): void { const element = node.element; data.element = element; element.element = data.entry ?? undefined; element.element.classList.toggle('focus-inside', !!element.focusInsideSeparator); const mainItem: IQuickPickSeparator = element.separator; const { labelHighlights, descriptionHighlights } = element; // Icon data.icon.style.backgroundImage = ''; data.icon.className = ''; // Label let descriptionTitle: IManagedHoverTooltipMarkdownString | undefined; // if we have a tooltip, that will be the hover, // with the saneDescription as fallback if it // is defined if (!element.saneTooltip && element.saneDescription) { descriptionTitle = { markdown: { value: escape(element.saneDescription), supportThemeIcons: true }, markdownNotSupportedFallback: element.saneDescription }; } const options: IIconLabelValueOptions = { matches: labelHighlights || [], // If we have a tooltip, we want that to be shown and not any other hover descriptionTitle, descriptionMatches: descriptionHighlights || [], labelEscapeNewLines: true }; data.entry.classList.add('quick-input-list-separator-as-item'); data.label.setLabel(element.saneLabel, element.saneDescription, options); // Separator data.separator.style.display = 'none'; data.entry.classList.add('quick-input-list-separator-border'); // Actions const buttons = mainItem.buttons; if (buttons && buttons.length) { data.actionBar.push(buttons.map((button, index) => quickInputButtonToAction( button, `id-${index}`, () => element.fireSeparatorButtonTriggered({ button, separator: element.separator }) )), { icon: true, label: false }); data.entry.classList.add('has-actions'); } else { data.entry.classList.remove('has-actions'); } this.addSeparator(element); } override disposeElement(element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { this.removeSeparator(element.element); if (!this.isSeparatorVisible(element.element)) { element.element.element?.classList.remove('focus-inside'); } super.disposeElement(element, _index, data); } private addSeparator(separator: QuickPickSeparatorElement): void { this._visibleSeparatorsFrequency.set(separator, (this._visibleSeparatorsFrequency.get(separator) || 0) + 1); } private removeSeparator(separator: QuickPickSeparatorElement): void { const frequency = this._visibleSeparatorsFrequency.get(separator) || 0; if (frequency > 1) { this._visibleSeparatorsFrequency.set(separator, frequency - 1); } else { this._visibleSeparatorsFrequency.delete(separator); } } } export class QuickInputTree extends Disposable { //#region QuickInputTree Events private readonly _onKeyDown = new Emitter(); /** * Event that is fired when the tree receives a keydown. */ readonly onKeyDown: Event = this._onKeyDown.event; private readonly _onLeave = new Emitter(); /** * Event that is fired when the tree would no longer have focus. */ readonly onLeave: Event = this._onLeave.event; private readonly _visibleCountObservable = observableValue('VisibleCount', 0); onChangedVisibleCount: Event = Event.fromObservable(this._visibleCountObservable, this._store); private readonly _allVisibleCheckedObservable = observableValue('AllVisibleChecked', false); onChangedAllVisibleChecked: Event = Event.fromObservable(this._allVisibleCheckedObservable, this._store); private readonly _checkedCountObservable = observableValue('CheckedCount', 0); onChangedCheckedCount: Event = Event.fromObservable(this._checkedCountObservable, this._store); private readonly _checkedElementsObservable = observableValueOpts({ equalsFn: equals }, new Array()); onChangedCheckedElements: Event = Event.fromObservable(this._checkedElementsObservable, this._store); private readonly _onButtonTriggered = new Emitter>(); onButtonTriggered = this._onButtonTriggered.event; private readonly _onSeparatorButtonTriggered = new Emitter(); onSeparatorButtonTriggered = this._onSeparatorButtonTriggered.event; private readonly _elementChecked = new Emitter<{ element: IQuickPickElement; checked: boolean }>(); private readonly _elementCheckedEventBufferer = new EventBufferer(); //#endregion private _hasCheckboxes = false; private readonly _container: HTMLElement; private readonly _tree: WorkbenchObjectTree; private readonly _separatorRenderer: QuickPickSeparatorElementRenderer; private readonly _itemRenderer: QuickPickItemElementRenderer; private _inputElements = new Array(); private _elementTree = new Array(); private _itemElements = new Array(); // Elements that apply to the current set of elements private readonly _elementDisposable = this._register(new DisposableStore()); private _lastHover: IHoverWidget | undefined; private _lastQueryString: string | undefined; constructor( private parent: HTMLElement, private hoverDelegate: IHoverDelegate, private linkOpenerDelegate: (content: string) => void, id: string, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService ) { super(); this._container = dom.append(this.parent, $('.quick-input-list')); this._separatorRenderer = new QuickPickSeparatorElementRenderer(hoverDelegate); this._itemRenderer = instantiationService.createInstance(QuickPickItemElementRenderer, hoverDelegate); this._tree = this._register(instantiationService.createInstance( WorkbenchObjectTree, 'QuickInput', this._container, new QuickInputItemDelegate(), [this._itemRenderer, this._separatorRenderer], { filter: { filter(element) { return element.hidden ? TreeVisibility.Hidden : element instanceof QuickPickSeparatorElement ? TreeVisibility.Recurse : TreeVisibility.Visible; }, }, sorter: { compare: (element, otherElement) => { if (!this.sortByLabel || !this._lastQueryString) { return 0; } const normalizedSearchValue = this._lastQueryString.toLowerCase(); return compareEntries(element, otherElement, normalizedSearchValue); }, }, accessibilityProvider: new QuickInputAccessibilityProvider(), setRowLineHeight: false, multipleSelectionSupport: false, hideTwistiesOfChildlessElements: true, renderIndentGuides: RenderIndentGuides.None, findWidgetEnabled: false, indent: 0, horizontalScrolling: false, allowNonCollapsibleParents: true, alwaysConsumeMouseWheel: true } )); this._tree.getHTMLElement().id = id; this._registerListeners(); } //#region public getters/setters @memoize get onDidChangeFocus() { return Event.map( this._tree.onDidChangeFocus, e => e.elements.filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement).map(e => e.item), this._store ); } @memoize get onDidChangeSelection() { return Event.map( this._tree.onDidChangeSelection, e => ({ items: e.elements.filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement).map(e => e.item), event: e.browserEvent }), this._store ); } get displayed() { return this._container.style.display !== 'none'; } set displayed(value: boolean) { this._container.style.display = value ? '' : 'none'; } get scrollTop() { return this._tree.scrollTop; } set scrollTop(scrollTop: number) { this._tree.scrollTop = scrollTop; } get ariaLabel() { return this._tree.ariaLabel; } set ariaLabel(label: string | null) { this._tree.ariaLabel = label ?? ''; } set enabled(value: boolean) { this._tree.getHTMLElement().style.pointerEvents = value ? '' : 'none'; } private _matchOnDescription = false; get matchOnDescription() { return this._matchOnDescription; } set matchOnDescription(value: boolean) { this._matchOnDescription = value; } private _matchOnDetail = false; get matchOnDetail() { return this._matchOnDetail; } set matchOnDetail(value: boolean) { this._matchOnDetail = value; } private _matchOnLabel = true; get matchOnLabel() { return this._matchOnLabel; } set matchOnLabel(value: boolean) { this._matchOnLabel = value; } private _matchOnLabelMode: 'fuzzy' | 'contiguous' = 'fuzzy'; get matchOnLabelMode() { return this._matchOnLabelMode; } set matchOnLabelMode(value: 'fuzzy' | 'contiguous') { this._matchOnLabelMode = value; } private _matchOnMeta = true; get matchOnMeta() { return this._matchOnMeta; } set matchOnMeta(value: boolean) { this._matchOnMeta = value; } private _sortByLabel = true; get sortByLabel() { return this._sortByLabel; } set sortByLabel(value: boolean) { this._sortByLabel = value; } private _shouldLoop = true; get shouldLoop() { return this._shouldLoop; } set shouldLoop(value: boolean) { this._shouldLoop = value; } //#endregion //#region register listeners private _registerListeners() { this._registerOnKeyDown(); this._registerOnContainerClick(); this._registerOnMouseMiddleClick(); this._registerOnTreeModelChanged(); this._registerOnElementChecked(); this._registerOnContextMenu(); this._registerHoverListeners(); this._registerSelectionChangeListener(); this._registerSeparatorActionShowingListeners(); } private _registerOnKeyDown() { // TODO: Should this be added at a higher level? this._register(this._tree.onKeyDown(e => { const event = new StandardKeyboardEvent(e); switch (event.keyCode) { case KeyCode.Space: this.toggleCheckbox(); break; } this._onKeyDown.fire(event); })); } private _registerOnContainerClick() { this._register(dom.addDisposableListener(this._container, dom.EventType.CLICK, e => { if (e.x || e.y) { // Avoid 'click' triggered by 'space' on checkbox. this._onLeave.fire(); } })); } private _registerOnMouseMiddleClick() { this._register(dom.addDisposableListener(this._container, dom.EventType.AUXCLICK, e => { if (e.button === 1) { this._onLeave.fire(); } })); } private _registerOnTreeModelChanged() { this._register(this._tree.onDidChangeModel(() => { const visibleCount = this._itemElements.filter(e => !e.hidden).length; this._visibleCountObservable.set(visibleCount, undefined); if (this._hasCheckboxes) { this._updateCheckedObservables(); } })); } private _registerOnElementChecked() { // Only fire the last event when buffered this._register(this._elementCheckedEventBufferer.wrapEvent(this._elementChecked.event, (_, e) => e)(_ => this._updateCheckedObservables())); } private _registerOnContextMenu() { this._register(this._tree.onContextMenu(e => { if (e.element) { e.browserEvent.preventDefault(); // we want to treat a context menu event as // a gesture to open the item at the index // since we do not have any context menu // this enables for example macOS to Ctrl- // click on an item to open it. this._tree.setSelection([e.element]); } })); } private _registerHoverListeners() { const delayer = this._register(new ThrottledDelayer(typeof this.hoverDelegate.delay === 'function' ? this.hoverDelegate.delay() : this.hoverDelegate.delay)); this._register(this._tree.onMouseOver(async e => { // If we hover over an anchor element, we don't want to show the hover because // the anchor may have a tooltip that we want to show instead. if (dom.isHTMLAnchorElement(e.browserEvent.target)) { delayer.cancel(); return; } if ( // anchors are an exception as called out above so we skip them here !(dom.isHTMLAnchorElement(e.browserEvent.relatedTarget)) && // check if the mouse is still over the same element dom.isAncestor(e.browserEvent.relatedTarget as Node, e.element?.element as Node) ) { return; } try { await delayer.trigger(async () => { if (e.element instanceof QuickPickItemElement) { this.showHover(e.element); } }); } catch (e) { // Ignore cancellation errors due to mouse out if (!isCancellationError(e)) { throw e; } } })); this._register(this._tree.onMouseOut(e => { // onMouseOut triggers every time a new element has been moused over // even if it's on the same list item. We only want one event, so we // check if the mouse is still over the same element. if (dom.isAncestor(e.browserEvent.relatedTarget as Node, e.element?.element as Node)) { return; } delayer.cancel(); })); } /** * Register's focus change and mouse events so that we can track when items inside of a * separator's section are focused or hovered so that we can display the separator's actions */ private _registerSeparatorActionShowingListeners() { this._register(this._tree.onDidChangeFocus(e => { const parent = e.elements[0] ? this._tree.getParentElement(e.elements[0]) as QuickPickSeparatorElement // treat null as focus lost and when we have no separators : null; for (const separator of this._separatorRenderer.visibleSeparators) { const value = separator === parent; // get bitness of ACTIVE_ITEM and check if it changed const currentActive = !!(separator.focusInsideSeparator & QuickPickSeparatorFocusReason.ACTIVE_ITEM); if (currentActive !== value) { if (value) { separator.focusInsideSeparator |= QuickPickSeparatorFocusReason.ACTIVE_ITEM; } else { separator.focusInsideSeparator &= ~QuickPickSeparatorFocusReason.ACTIVE_ITEM; } this._tree.rerender(separator); } } })); this._register(this._tree.onMouseOver(e => { const parent = e.element ? this._tree.getParentElement(e.element) as QuickPickSeparatorElement : null; for (const separator of this._separatorRenderer.visibleSeparators) { if (separator !== parent) { continue; } const currentMouse = !!(separator.focusInsideSeparator & QuickPickSeparatorFocusReason.MOUSE_HOVER); if (!currentMouse) { separator.focusInsideSeparator |= QuickPickSeparatorFocusReason.MOUSE_HOVER; this._tree.rerender(separator); } } })); this._register(this._tree.onMouseOut(e => { const parent = e.element ? this._tree.getParentElement(e.element) as QuickPickSeparatorElement : null; for (const separator of this._separatorRenderer.visibleSeparators) { if (separator !== parent) { continue; } const currentMouse = !!(separator.focusInsideSeparator & QuickPickSeparatorFocusReason.MOUSE_HOVER); if (currentMouse) { separator.focusInsideSeparator &= ~QuickPickSeparatorFocusReason.MOUSE_HOVER; this._tree.rerender(separator); } } })); } private _registerSelectionChangeListener() { // When the user selects a separator, the separator will move to the top and focus will be // set to the first element after the separator. this._register(this._tree.onDidChangeSelection(e => { const elementsWithoutSeparators = e.elements.filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement); if (elementsWithoutSeparators.length !== e.elements.length) { if (e.elements.length === 1 && e.elements[0] instanceof QuickPickSeparatorElement) { this._tree.setFocus([e.elements[0].children[0]]); this._tree.reveal(e.elements[0], 0); } this._tree.setSelection(elementsWithoutSeparators); } })); } //#endregion //#region public methods setAllVisibleChecked(checked: boolean) { this._elementCheckedEventBufferer.bufferEvents(() => { this._itemElements.forEach(element => { if (!element.hidden && !element.checkboxDisabled && element.item.pickable !== false) { // Would fire an event if we didn't beffer the events element.checked = checked; } }); }); } setElements(inputElements: QuickPickItem[]): void { this._elementDisposable.clear(); this._lastQueryString = undefined; this._inputElements = inputElements; this._hasCheckboxes = this.parent.classList.contains('show-checkboxes'); let currentSeparatorElement: QuickPickSeparatorElement | undefined; this._itemElements = new Array(); this._elementTree = inputElements.reduce((result, item, index) => { let element: IQuickPickElement; if (item.type === 'separator') { if (!item.buttons) { // This separator will be rendered as a part of the list item return result; } currentSeparatorElement = new QuickPickSeparatorElement( index, e => this._onSeparatorButtonTriggered.fire(e), item ); element = currentSeparatorElement; } else { const previous = index > 0 ? inputElements[index - 1] : undefined; let separator: IQuickPickSeparator | undefined; if (previous && previous.type === 'separator' && !previous.buttons) { // Found an inline separator so we clear out the current separator element currentSeparatorElement = undefined; separator = previous; } const qpi = new QuickPickItemElement( index, this._hasCheckboxes && item.pickable !== false, e => this._onButtonTriggered.fire(e), this._elementChecked, item, separator, ); this._itemElements.push(qpi); if (currentSeparatorElement) { currentSeparatorElement.children.push(qpi); return result; } element = qpi; } result.push(element); return result; }, new Array()); this._setElementsToTree(this._elementTree); // Accessibility hack, unfortunately on next tick // https://github.com/microsoft/vscode/issues/211976 if (this.accessibilityService.isScreenReaderOptimized()) { setTimeout(() => { const focusedElement = this._tree.getHTMLElement().querySelector(`.monaco-list-row.focused`); const parent = focusedElement?.parentNode; if (focusedElement && parent) { const nextSibling = focusedElement.nextSibling; focusedElement.remove(); parent.insertBefore(focusedElement, nextSibling); } }, 0); } } setFocusedElements(items: IQuickPickItem[]) { const elements = items.map(item => this._itemElements.find(e => e.item === item)) .filter((e): e is QuickPickItemElement => !!e) .filter(e => !e.hidden); this._tree.setFocus(elements); if (items.length > 0) { const focused = this._tree.getFocus()[0]; if (focused) { this._tree.reveal(focused); } } } getActiveDescendant() { return this._tree.getHTMLElement().getAttribute('aria-activedescendant'); } setSelectedElements(items: IQuickPickItem[]) { const elements = items.map(item => this._itemElements.find(e => e.item === item)) .filter((e): e is QuickPickItemElement => !!e); this._tree.setSelection(elements); } getCheckedElements() { return this._itemElements.filter(e => e.checked) .map(e => e.item); } setCheckedElements(items: IQuickPickItem[]) { this._elementCheckedEventBufferer.bufferEvents(() => { const checked = new Set(); for (const item of items) { checked.add(item); } for (const element of this._itemElements) { // Would fire an event if we didn't beffer the events element.checked = checked.has(element.item); } }); } focus(what: QuickPickFocus): void { if (!this._itemElements.length) { return; } if (what === QuickPickFocus.Second && this._itemElements.length < 2) { what = QuickPickFocus.First; } switch (what) { case QuickPickFocus.First: this._tree.scrollTop = 0; this._tree.focusFirst(undefined, (e) => e.element instanceof QuickPickItemElement); break; case QuickPickFocus.Second: { this._tree.scrollTop = 0; let isSecondItem = false; this._tree.focusFirst(undefined, (e) => { if (!(e.element instanceof QuickPickItemElement)) { return false; } if (isSecondItem) { return true; } isSecondItem = !isSecondItem; return false; }); break; } case QuickPickFocus.Last: this._tree.scrollTop = this._tree.scrollHeight; this._tree.focusLast(undefined, (e) => e.element instanceof QuickPickItemElement); break; case QuickPickFocus.Next: { const prevFocus = this._tree.getFocus(); this._tree.focusNext(undefined, this._shouldLoop, undefined, (e) => { if (!(e.element instanceof QuickPickItemElement)) { return false; } this._tree.reveal(e.element); return true; }); const currentFocus = this._tree.getFocus(); if (prevFocus.length && prevFocus[0] === currentFocus[0]) { this._onLeave.fire(); } break; } case QuickPickFocus.Previous: { const prevFocus = this._tree.getFocus(); this._tree.focusPrevious(undefined, this._shouldLoop, undefined, (e) => { if (!(e.element instanceof QuickPickItemElement)) { return false; } const parent = this._tree.getParentElement(e.element); if (parent === null || (parent as QuickPickSeparatorElement).children[0] !== e.element) { this._tree.reveal(e.element); } else { // Only if we are the first child of a separator do we reveal the separator this._tree.reveal(parent); } return true; }); const currentFocus = this._tree.getFocus(); if (prevFocus.length && prevFocus[0] === currentFocus[0]) { this._onLeave.fire(); } break; } case QuickPickFocus.NextPage: this._tree.focusNextPage(undefined, (e) => { if (!(e.element instanceof QuickPickItemElement)) { return false; } this._tree.reveal(e.element); return true; }); break; case QuickPickFocus.PreviousPage: this._tree.focusPreviousPage(undefined, (e) => { if (!(e.element instanceof QuickPickItemElement)) { return false; } const parent = this._tree.getParentElement(e.element); if (parent === null || (parent as QuickPickSeparatorElement).children[0] !== e.element) { this._tree.reveal(e.element); } else { this._tree.reveal(parent); } return true; }); break; case QuickPickFocus.NextSeparator: { let foundSeparatorAsItem = false; const before = this._tree.getFocus()[0]; this._tree.focusNext(undefined, true, undefined, (e) => { if (foundSeparatorAsItem) { // This should be the index right after the separator so it // is the item we want to focus. return true; } if (e.element instanceof QuickPickSeparatorElement) { foundSeparatorAsItem = true; // If the separator is visible, then we should just reveal its first child so it's not as jarring. if (this._separatorRenderer.isSeparatorVisible(e.element)) { this._tree.reveal(e.element.children[0]); } else { // If the separator is not visible, then we should // push it up to the top of the list. this._tree.reveal(e.element, 0); } } else if (e.element instanceof QuickPickItemElement) { if (e.element.separator) { if (this._itemRenderer.isItemWithSeparatorVisible(e.element)) { this._tree.reveal(e.element); } else { this._tree.reveal(e.element, 0); } return true; } else if (e.element === this._elementTree[0]) { // We should stop at the first item in the list if it's a regular item. this._tree.reveal(e.element, 0); return true; } } return false; }); const after = this._tree.getFocus()[0]; if (before === after) { // If we didn't move, then we should just move to the end // of the list. this._tree.scrollTop = this._tree.scrollHeight; this._tree.focusLast(undefined, (e) => e.element instanceof QuickPickItemElement); } break; } case QuickPickFocus.PreviousSeparator: { let focusElement: IQuickPickElement | undefined; // If we are already sitting on an inline separator, then we // have already found the _current_ separator and need to // move to the previous one. let foundSeparator = !!this._tree.getFocus()[0]?.separator; this._tree.focusPrevious(undefined, true, undefined, (e) => { if (e.element instanceof QuickPickSeparatorElement) { if (foundSeparator) { if (!focusElement) { if (this._separatorRenderer.isSeparatorVisible(e.element)) { this._tree.reveal(e.element); } else { this._tree.reveal(e.element, 0); } focusElement = e.element.children[0]; } } else { foundSeparator = true; } } else if (e.element instanceof QuickPickItemElement) { if (!focusElement) { if (e.element.separator) { if (this._itemRenderer.isItemWithSeparatorVisible(e.element)) { this._tree.reveal(e.element); } else { this._tree.reveal(e.element, 0); } focusElement = e.element; } else if (e.element === this._elementTree[0]) { // We should stop at the first item in the list if it's a regular item. this._tree.reveal(e.element, 0); return true; } } } return false; }); if (focusElement) { this._tree.setFocus([focusElement]); } break; } } } clearFocus() { this._tree.setFocus([]); } domFocus() { this._tree.domFocus(); } layout(maxHeight?: number): void { this._tree.getHTMLElement().style.maxHeight = maxHeight ? `${ // Make sure height aligns with list item heights Math.floor(maxHeight / 44) * 44 // Add some extra height so that it's clear there's more to scroll + 6 }px` : ''; this._tree.layout(); } filter(query: string): boolean { this._lastQueryString = query; if (!(this._sortByLabel || this._matchOnLabel || this._matchOnDescription || this._matchOnDetail)) { this._tree.layout(); return false; } const queryWithWhitespace = query; query = query.trim(); // Reset filtering if (!query || !(this.matchOnLabel || this.matchOnDescription || this.matchOnDetail)) { this._itemElements.forEach(element => { element.labelHighlights = undefined; element.descriptionHighlights = undefined; element.detailHighlights = undefined; element.hidden = false; const previous = element.index && this._inputElements[element.index - 1]; if (element.item) { element.separator = previous && previous.type === 'separator' && !previous.buttons ? previous : undefined; } }); } // Filter by value (since we support icons in labels, use $(..) aware fuzzy matching) else { let currentSeparator: IQuickPickSeparator | undefined; this._itemElements.forEach(element => { let labelHighlights: IMatch[] | undefined; if (this.matchOnLabelMode === 'fuzzy') { labelHighlights = this.matchOnLabel ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneLabel)) ?? undefined : undefined; } else { labelHighlights = this.matchOnLabel ? matchesContiguousIconAware(queryWithWhitespace, parseLabelWithIcons(element.saneLabel)) ?? undefined : undefined; } const descriptionHighlights = this.matchOnDescription ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneDescription || '')) ?? undefined : undefined; const detailHighlights = this.matchOnDetail ? matchesFuzzyIconAware(query, parseLabelWithIcons(element.saneDetail || '')) ?? undefined : undefined; if (labelHighlights || descriptionHighlights || detailHighlights) { element.labelHighlights = labelHighlights; element.descriptionHighlights = descriptionHighlights; element.detailHighlights = detailHighlights; element.hidden = false; } else { element.labelHighlights = undefined; element.descriptionHighlights = undefined; element.detailHighlights = undefined; element.hidden = element.item ? !element.item.alwaysShow : true; } // Ensure separators are filtered out first before deciding if we need to bring them back if (element.item) { element.separator = undefined; } else if (element.separator) { element.hidden = true; } // we can show the separator unless the list gets sorted by match if (!this.sortByLabel) { const previous = element.index && this._inputElements[element.index - 1] || undefined; if (previous?.type === 'separator' && !previous.buttons) { currentSeparator = previous; } if (currentSeparator && !element.hidden) { element.separator = currentSeparator; currentSeparator = undefined; } } }); } this._setElementsToTree(this._sortByLabel && query // We don't render any separators if we're sorting so just render the elements ? this._itemElements // Render the full tree : this._elementTree ); this._tree.layout(); return true; } toggleCheckbox() { this._elementCheckedEventBufferer.bufferEvents(() => { const elements = this._tree.getFocus().filter((e): e is QuickPickItemElement => e instanceof QuickPickItemElement); const allChecked = this._allVisibleChecked(elements); for (const element of elements) { if (!element.checkboxDisabled) { // Would fire an event if we didn't have the flag set element.checked = !allChecked; } } }); } style(styles: IListStyles) { this._tree.style(styles); } toggleHover() { const focused: IQuickPickElement | null = this._tree.getFocus()[0]; if (!focused?.saneTooltip || !(focused instanceof QuickPickItemElement)) { return; } // if there's a hover already, hide it (toggle off) if (this._lastHover && !this._lastHover.isDisposed) { this._lastHover.dispose(); return; } // If there is no hover, show it (toggle on) this.showHover(focused); const store = new DisposableStore(); store.add(this._tree.onDidChangeFocus(e => { if (e.elements[0] instanceof QuickPickItemElement) { this.showHover(e.elements[0]); } })); if (this._lastHover) { store.add(this._lastHover); } this._elementDisposable.add(store); } //#endregion //#region private methods private _setElementsToTree(elements: IQuickPickElement[]) { const treeElements = new Array>(); for (const element of elements) { if (element instanceof QuickPickSeparatorElement) { treeElements.push({ element, collapsible: false, collapsed: false, children: element.children.map(e => ({ element: e, collapsible: false, collapsed: false, })), }); } else { treeElements.push({ element, collapsible: false, collapsed: false, }); } } this._tree.setChildren(null, treeElements); } private _allVisibleChecked(elements: QuickPickItemElement[], whenNoneVisible = true) { for (let i = 0, n = elements.length; i < n; i++) { const element = elements[i]; if (!element.hidden && element.item.pickable !== false) { if (!element.checked) { return false; } else { whenNoneVisible = true; } } } return whenNoneVisible; } private _updateCheckedObservables() { transaction((tx) => { this._allVisibleCheckedObservable.set(this._allVisibleChecked(this._itemElements, false), tx); const checkedCount = this._itemElements.filter(element => element.checked).length; this._checkedCountObservable.set(checkedCount, tx); this._checkedElementsObservable.set(this.getCheckedElements(), tx); }); } /** * Disposes of the hover and shows a new one for the given index if it has a tooltip. * @param element The element to show the hover for */ private showHover(element: QuickPickItemElement): void { if (this._lastHover && !this._lastHover.isDisposed) { this.hoverDelegate.onDidHideHover?.(); this._lastHover?.dispose(); } if (!element.element || !element.saneTooltip) { return; } this._lastHover = this.hoverDelegate.showHover({ content: element.saneTooltip, target: element.element, linkHandler: (url) => { this.linkOpenerDelegate(url); }, appearance: { showPointer: true, }, container: this._container, position: { hoverPosition: HoverPosition.RIGHT } }, false); } } function matchesContiguousIconAware(query: string, target: IParsedLabelWithIcons): IMatch[] | null { const { text, iconOffsets } = target; // Return early if there are no icon markers in the word to match against if (!iconOffsets || iconOffsets.length === 0) { return matchesContiguous(query, text); } // Trim the word to match against because it could have leading // whitespace now if the word started with an icon const wordToMatchAgainstWithoutIconsTrimmed = ltrim(text, ' '); const leadingWhitespaceOffset = text.length - wordToMatchAgainstWithoutIconsTrimmed.length; // match on value without icon const matches = matchesContiguous(query, wordToMatchAgainstWithoutIconsTrimmed); // Map matches back to offsets with icon and trimming if (matches) { for (const match of matches) { const iconOffset = iconOffsets[match.start + leadingWhitespaceOffset] /* icon offsets at index */ + leadingWhitespaceOffset /* overall leading whitespace offset */; match.start += iconOffset; match.end += iconOffset; } } return matches; } function matchesContiguous(word: string, wordToMatchAgainst: string): IMatch[] | null { const matchIndex = wordToMatchAgainst.toLowerCase().indexOf(word.toLowerCase()); if (matchIndex !== -1) { return [{ start: matchIndex, end: matchIndex + word.length }]; } return null; } function compareEntries(elementA: IQuickPickElement, elementB: IQuickPickElement, lookFor: string): number { const labelHighlightsA = elementA.labelHighlights || []; const labelHighlightsB = elementB.labelHighlights || []; if (labelHighlightsA.length && !labelHighlightsB.length) { return -1; } if (!labelHighlightsA.length && labelHighlightsB.length) { return 1; } if (labelHighlightsA.length === 0 && labelHighlightsB.length === 0) { return 0; } return compareAnything(elementA.saneSortLabel, elementB.saneSortLabel, lookFor); }