From f861acbee1d4cf8cb19fbf4a415bfb97ab211df3 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt Date: Wed, 15 Feb 2023 08:35:09 -0800 Subject: [PATCH] Enable a tooltip in quick pick (#174417) * hover in quickpick * refactoring * fix tests * remove HTMLElement from API for now * API proposal --- .../browser/ui/iconLabel/iconHoverDelegate.ts | 29 +++++ .../platform/quickinput/browser/quickInput.ts | 8 ++ .../quickinput/browser/quickInputList.ts | 110 +++++++++++++++++- .../quickinput/browser/quickInputService.ts | 6 + .../platform/quickinput/common/quickInput.ts | 3 + .../test/browser/quickinput.test.ts | 6 + .../workbench/api/common/extHost.api.impl.ts | 2 +- .../workbench/api/common/extHostQuickOpen.ts | 32 +++-- .../common/extensionsApiProposals.ts | 1 + .../quickinput/browser/quickInputService.ts | 40 ++++++- .../test/browser/workbenchTestServices.ts | 22 +++- .../vscode.proposed.quickPickItemTooltip.d.ts | 16 +++ 12 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 src/vscode-dts/vscode.proposed.quickPickItemTooltip.d.ts diff --git a/src/vs/base/browser/ui/iconLabel/iconHoverDelegate.ts b/src/vs/base/browser/ui/iconLabel/iconHoverDelegate.ts index 1dd1e111bd9..e649c8f0c4a 100644 --- a/src/vs/base/browser/ui/iconLabel/iconHoverDelegate.ts +++ b/src/vs/base/browser/ui/iconLabel/iconHoverDelegate.ts @@ -14,11 +14,40 @@ export interface IHoverDelegateTarget extends IDisposable { } export interface IHoverDelegateOptions extends IUpdatableHoverOptions { + /** + * The content to display in the primary section of the hover. The type of text determines the + * default `hideOnHover` behavior. + */ content: IMarkdownString | string | HTMLElement; + /** + * The target for the hover. This determines the position of the hover and it will only be + * hidden when the mouse leaves both the hover and the target. A HTMLElement can be used for + * simple cases and a IHoverDelegateTarget for more complex cases where multiple elements and/or a + * dispose method is required. + */ target: IHoverDelegateTarget | HTMLElement; + /** + * Position of the hover. The default is to show above the target. This option will be ignored + * if there is not enough room to layout the hover in the specified position, unless the + * forcePosition option is set. + */ hoverPosition?: HoverPosition; + /** + * Whether to show the hover pointer + */ showPointer?: boolean; + /** + * Whether to skip the fade in animation, this should be used when hovering from one hover to + * another in the same group so it looks like the hover is moving from one element to the other. + */ skipFadeInAnimation?: boolean; + /** + * The container to pass to {@link IContextViewProvider.showContextView} which renders the hover + * in. This is particularly useful for more natural tab focusing behavior, where the hover is + * created as the next tab index after the element being hovered and/or to workaround the + * element's container hiding on `focusout`. + */ + container?: HTMLElement; } export interface IHoverDelegate { diff --git a/src/vs/platform/quickinput/browser/quickInput.ts b/src/vs/platform/quickinput/browser/quickInput.ts index 798f6ccb2f6..8c1786f8654 100644 --- a/src/vs/platform/quickinput/browser/quickInput.ts +++ b/src/vs/platform/quickinput/browser/quickInput.ts @@ -9,6 +9,7 @@ import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; import { Button, IButtonStyles } from 'vs/base/browser/ui/button/button'; import { CountBadge, ICountBadgeStyles } from 'vs/base/browser/ui/countBadge/countBadge'; +import { IHoverDelegate } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; import { IInputBoxStyles } from 'vs/base/browser/ui/inputbox/inputBox'; import { IKeybindingLabelStyles } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; @@ -51,6 +52,7 @@ export interface IQuickInputOptions { renderers: IListRenderer[], options: IListOptions, ): List; + hoverDelegate: IHoverDelegate; styles: IQuickInputStyles; } @@ -1416,6 +1418,12 @@ export class QuickInputController extends Disposable { } } break; + case KeyCode.Space: + if (event.ctrlKey) { + dom.EventHelper.stop(e, true); + this.getUI().list.toggleHover(); + } + break; } })); diff --git a/src/vs/platform/quickinput/browser/quickInputList.ts b/src/vs/platform/quickinput/browser/quickInputList.ts index 30aea174bf6..650b9aa65ee 100644 --- a/src/vs/platform/quickinput/browser/quickInputList.ts +++ b/src/vs/platform/quickinput/browser/quickInputList.ts @@ -7,19 +7,23 @@ import * as dom from 'vs/base/browser/dom'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { AriaRole } from 'vs/base/browser/ui/aria/aria'; +import { HoverPosition } from 'vs/base/browser/ui/hover/hoverWidget'; +import { IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; import { IconLabel, IIconLabelValueOptions } from 'vs/base/browser/ui/iconLabel/iconLabel'; import { KeybindingLabel } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel'; import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { IListAccessibilityProvider, IListOptions, IListStyles, List } from 'vs/base/browser/ui/list/listWidget'; import { IAction } from 'vs/base/common/actions'; import { range } from 'vs/base/common/arrays'; +import { ThrottledDelayer } from 'vs/base/common/async'; import { compareAnything } from 'vs/base/common/comparers'; import { memoize } from 'vs/base/common/decorators'; import { Emitter, Event } from 'vs/base/common/event'; import { IMatch } from 'vs/base/common/filters'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; import { getCodiconAriaLabel, IParsedLabelWithIcons, matchesFuzzyIconAware, parseLabelWithIcons } from 'vs/base/common/iconLabels'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, dispose, IDisposable } from 'vs/base/common/lifecycle'; import * as platform from 'vs/base/common/platform'; import { ltrim } from 'vs/base/common/strings'; import { withNullAsUndefined } from 'vs/base/common/types'; @@ -41,6 +45,7 @@ interface IListElement { readonly saneAriaLabel: string; readonly saneDescription?: string; readonly saneDetail?: string; + readonly saneTooltip?: string | IMarkdownString | HTMLElement; readonly labelHighlights?: IMatch[]; readonly descriptionHighlights?: IMatch[]; readonly detailHighlights?: IMatch[]; @@ -60,6 +65,8 @@ class ListElement implements IListElement, IDisposable { saneAriaLabel!: string; saneDescription?: string; saneDetail?: string; + saneTooltip?: string | IMarkdownString | HTMLElement; + element?: HTMLElement; hidden = false; private readonly _onChecked = new Emitter(); onChecked = this._onChecked.event; @@ -159,6 +166,7 @@ class ListElementRenderer implements IListRenderer { + // 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 (e.browserEvent.target instanceof HTMLAnchorElement) { + delayer.cancel(); + return; + } + if ( + // anchors are an exception as called out above so we skip them here + !(e.browserEvent.relatedTarget instanceof HTMLAnchorElement) && + // check if the mouse is still over the same element + dom.isAncestor(e.browserEvent.relatedTarget as Node, e.element?.element as Node) + ) { + return; + } + await delayer.trigger(async () => { + if (e.element) { + this.showHover(e.element); + } + }); + })); + this.disposables.push(this.list.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(); + })); this.disposables.push( this._onChangedAllVisibleChecked, this._onChangedCheckedCount, @@ -389,7 +433,8 @@ export class QuickInputList { this._onButtonTriggered, this._onSeparatorButtonTriggered, this._onLeave, - this._onKeyDown + this._onKeyDown, + delayer ); } @@ -475,7 +520,7 @@ export class QuickInputList { const saneLabel = item.label ? item.label.replace(/\r?\n/g, ' ') : ''; const saneSortLabel = parseLabelWithIcons(saneLabel).text.trim(); - let saneMeta, saneDescription, saneDetail, labelHighlights, descriptionHighlights, detailHighlights; + let saneMeta, saneDescription, saneDetail, labelHighlights, descriptionHighlights, detailHighlights, saneTooltip; if (item.type !== 'separator') { saneMeta = item.meta && item.meta.replace(/\r?\n/g, ' '); saneDescription = item.description && item.description.replace(/\r?\n/g, ' '); @@ -483,6 +528,7 @@ export class QuickInputList { labelHighlights = item.highlights?.label; descriptionHighlights = item.highlights?.description; detailHighlights = item.highlights?.detail; + saneTooltip = item.tooltip; } const saneAriaLabel = item.ariaLabel || [saneLabel, saneDescription, saneDetail] .map(s => getCodiconAriaLabel(s)) @@ -512,6 +558,7 @@ export class QuickInputList { saneAriaLabel, saneDescription, saneDetail, + saneTooltip, labelHighlights, descriptionHighlights, detailHighlights, @@ -659,6 +706,30 @@ export class QuickInputList { this.list.domFocus(); } + /** + * 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: ListElement): void { + if (this._lastHover && !this._lastHover.isDisposed) { + this.options.hoverDelegate.onDidHideHover?.(); + this._lastHover?.dispose(); + } + if (!element.element || !element.saneTooltip) { + return; + } + this._lastHover = this.options.hoverDelegate.showHover({ + content: element.saneTooltip!, + target: element.element!, + linkHandler: (url) => { + this.options.linkOpenerDelegate(url); + }, + showPointer: true, + container: this.container, + hoverPosition: HoverPosition.RIGHT + }, false); + } + layout(maxHeight?: number): void { this.list.getHTMLElement().style.maxHeight = maxHeight ? `calc(${Math.floor(maxHeight / 44) * 44}px)` : ''; this.list.layout(); @@ -795,6 +866,37 @@ export class QuickInputList { style(styles: IListStyles) { this.list.style(styles); } + + toggleHover() { + const element = this.list.getFocusedElements()[0]; + if (!element.saneTooltip) { + 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) + const focused = this.list.getFocusedElements()[0]; + if (!focused) { + return; + } + this.showHover(focused); + const store = new DisposableStore(); + store.add(this.list.onDidChangeFocus(e => { + if (e.indexes.length) { + this.showHover(e.elements[0]); + } + })); + if (this._lastHover) { + store.add(this._lastHover); + } + this._toggleHover = store; + this.elementDisposables.push(this._toggleHover); + } } function matchesContiguousIconAware(query: string, target: IParsedLabelWithIcons): IMatch[] | null { diff --git a/src/vs/platform/quickinput/browser/quickInputService.ts b/src/vs/platform/quickinput/browser/quickInputService.ts index 90b8ed4ef38..5e7473475d7 100644 --- a/src/vs/platform/quickinput/browser/quickInputService.ts +++ b/src/vs/platform/quickinput/browser/quickInputService.ts @@ -89,6 +89,12 @@ export class QuickInputService extends Themable implements IQuickInputService { renderers: IListRenderer[], options: IWorkbenchListOptions ) => this.instantiationService.createInstance(WorkbenchList, user, container, delegate, renderers, options) as List, + hoverDelegate: { + showHover(options, focus) { + return undefined; + }, + delay: 200 + }, styles: this.computeStyles() }; diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index ff260f92f56..7e3bd52dd2d 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -14,6 +14,7 @@ import { IDisposable } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; import Severity from 'vs/base/common/severity'; import { URI } from 'vs/base/common/uri'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; export interface IQuickPickItemHighlights { label?: IMatch[]; @@ -31,6 +32,7 @@ export interface IQuickPickItem { ariaLabel?: string; description?: string; detail?: string; + tooltip?: string | IMarkdownString; /** * Allows to show a keybinding next to the item to indicate * how the item can be triggered outside of the picker using @@ -52,6 +54,7 @@ export interface IQuickPickSeparator { label?: string; ariaLabel?: string; buttons?: readonly IQuickInputButton[]; + tooltip?: string | IMarkdownString; } export interface IKeyMods { diff --git a/src/vs/platform/quickinput/test/browser/quickinput.test.ts b/src/vs/platform/quickinput/test/browser/quickinput.test.ts index 5e74d3548bf..34a0547388b 100644 --- a/src/vs/platform/quickinput/test/browser/quickinput.test.ts +++ b/src/vs/platform/quickinput/test/browser/quickinput.test.ts @@ -58,6 +58,12 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 renderers: IListRenderer[], options: IListOptions, ) => new List(user, container, delegate, renderers, options), + hoverDelegate: { + showHover(options, focus) { + return undefined; + }, + delay: 200 + }, styles: { button: unthemedButtonStyles, countBadge: unthemedCountStyles, diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 27edca9fa34..8886bf9d058 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -670,7 +670,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return >extHostMessageService.showMessage(extension, Severity.Error, message, rest[0], >rest.slice(1)); }, showQuickPick(items: any, options?: vscode.QuickPickOptions, token?: vscode.CancellationToken): any { - return extHostQuickOpen.showQuickPick(items, options, token); + return extHostQuickOpen.showQuickPick(extension, items, options, token); }, showWorkspaceFolderPick(options?: vscode.WorkspaceFolderPickOptions) { return extHostQuickOpen.showWorkspaceFolderPick(options); diff --git a/src/vs/workbench/api/common/extHostQuickOpen.ts b/src/vs/workbench/api/common/extHostQuickOpen.ts index 4ae22adf430..558c61f22fb 100644 --- a/src/vs/workbench/api/common/extHostQuickOpen.ts +++ b/src/vs/workbench/api/common/extHostQuickOpen.ts @@ -17,14 +17,16 @@ import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensio import { coalesce } from 'vs/base/common/arrays'; import Severity from 'vs/base/common/severity'; import { ThemeIcon as ThemeIconUtils } from 'vs/base/common/themables'; +import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import { MarkdownString } from 'vs/workbench/api/common/extHostTypeConverters'; export type Item = string | QuickPickItem; export interface ExtHostQuickOpen { - showQuickPick(itemsOrItemsPromise: QuickPickItem[] | Promise, options: QuickPickOptions & { canPickMany: true }, token?: CancellationToken): Promise; - showQuickPick(itemsOrItemsPromise: string[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; - showQuickPick(itemsOrItemsPromise: QuickPickItem[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; - showQuickPick(itemsOrItemsPromise: Item[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: QuickPickItem[] | Promise, options: QuickPickOptions & { canPickMany: true }, token?: CancellationToken): Promise; + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: string[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: QuickPickItem[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: Item[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; showInput(options?: InputBoxOptions, token?: CancellationToken): Promise; @@ -55,11 +57,10 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx this._commands = commands; } - showQuickPick(itemsOrItemsPromise: QuickPickItem[] | Promise, options: QuickPickOptions & { canPickMany: true }, token?: CancellationToken): Promise; - showQuickPick(itemsOrItemsPromise: string[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; - showQuickPick(itemsOrItemsPromise: QuickPickItem[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; - showQuickPick(itemsOrItemsPromise: Item[] | Promise, options?: QuickPickOptions, token: CancellationToken = CancellationToken.None): Promise { - + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: QuickPickItem[] | Promise, options: QuickPickOptions & { canPickMany: true }, token?: CancellationToken): Promise; + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: string[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: QuickPickItem[] | Promise, options?: QuickPickOptions, token?: CancellationToken): Promise; + showQuickPick(extension: IExtensionDescription, itemsOrItemsPromise: Item[] | Promise, options?: QuickPickOptions, token: CancellationToken = CancellationToken.None): Promise { // clear state from last invocation this._onDidSelectItem = undefined; @@ -84,6 +85,8 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx return undefined; } + const allowedTooltips = isProposedApiEnabled(extension, 'quickPickItemTooltip'); + return itemsPromise.then(items => { const pickItems: TransferQuickPickItemOrSeparator[] = []; @@ -94,12 +97,16 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx } else if (item.kind === QuickPickItemKind.Separator) { pickItems.push({ type: 'separator', label: item.label }); } else { + if (item.tooltip && !allowedTooltips) { + console.warn(`Extension '${extension.identifier.value}' uses a tooltip which is proposed API that is only available when running out of dev or with the following command line switch: --enable-proposed-api ${extension.identifier.value}`); + } pickItems.push({ label: item.label, description: item.description, detail: item.detail, picked: item.picked, alwaysShow: item.alwaysShow, + tooltip: allowedTooltips ? MarkdownString.fromStrict(item.tooltip) : undefined, handle }); } @@ -535,7 +542,7 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx private readonly _onDidChangeSelectionEmitter = new Emitter(); private readonly _onDidTriggerItemButtonEmitter = new Emitter>(); - constructor(extension: IExtensionDescription, onDispose: () => void) { + constructor(private extension: IExtensionDescription, onDispose: () => void) { super(extension.identifier, onDispose); this._disposables.push( this._onDidChangeActiveEmitter, @@ -558,12 +565,16 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx this._itemsToHandles.set(item, i); }); + const allowedTooltips = isProposedApiEnabled(this.extension, 'quickPickItemTooltip'); const pickItems: TransferQuickPickItemOrSeparator[] = []; for (let handle = 0; handle < items.length; handle++) { const item = items[handle]; if (item.kind === QuickPickItemKind.Separator) { pickItems.push({ type: 'separator', label: item.label }); } else { + if (item.tooltip && !allowedTooltips) { + console.warn(`Extension '${this.extension.identifier.value}' uses a tooltip which is proposed API that is only available when running out of dev or with the following command line switch: --enable-proposed-api ${this.extension.identifier.value}`); + } pickItems.push({ handle, label: item.label, @@ -571,6 +582,7 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx detail: item.detail, picked: item.picked, alwaysShow: item.alwaysShow, + tooltip: allowedTooltips ? MarkdownString.fromStrict(item.tooltip) : undefined, buttons: item.buttons?.map((button, i) => { return { ...getIconPathOrClass(button), diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 8929645d6dd..9f06058245a 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -51,6 +51,7 @@ export const allApiProposals = Object.freeze({ portsAttributes: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.portsAttributes.d.ts', profileContentHandlers: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.profileContentHandlers.d.ts', quickDiffProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.quickDiffProvider.d.ts', + quickPickItemTooltip: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.quickPickItemTooltip.d.ts', quickPickSortByLabel: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.quickPickSortByLabel.d.ts', resolvers: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.resolvers.d.ts', scmActionButton: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.scmActionButton.d.ts', diff --git a/src/vs/workbench/services/quickinput/browser/quickInputService.ts b/src/vs/workbench/services/quickinput/browser/quickInputService.ts index 95122549d9f..77498318db2 100644 --- a/src/vs/workbench/services/quickinput/browser/quickInputService.ts +++ b/src/vs/workbench/services/quickinput/browser/quickInputService.ts @@ -15,9 +15,12 @@ import { QuickInputService as BaseQuickInputService } from 'vs/platform/quickinp import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { InQuickPickContextKey } from 'vs/workbench/browser/quickaccess'; +import { IHoverService } from 'vs/workbench/services/hover/browser/hover'; +import { IHoverDelegate, IHoverDelegateOptions, IHoverWidget } from 'vs/base/browser/ui/iconLabel/iconHoverDelegate'; export class QuickInputService extends BaseQuickInputService { + private readonly hoverDelegate = new QuickInputHoverDelegate(this.configurationService, this.hoverService); private readonly inQuickInputContext = InQuickPickContextKey.bindTo(this.contextKeyService); constructor( @@ -27,7 +30,8 @@ export class QuickInputService extends BaseQuickInputService { @IContextKeyService contextKeyService: IContextKeyService, @IThemeService themeService: IThemeService, @IAccessibilityService accessibilityService: IAccessibilityService, - @ILayoutService layoutService: ILayoutService + @ILayoutService layoutService: ILayoutService, + @IHoverService private readonly hoverService: IHoverService ) { super(instantiationService, contextKeyService, themeService, accessibilityService, layoutService); @@ -42,9 +46,41 @@ export class QuickInputService extends BaseQuickInputService { protected override createController(): QuickInputController { return super.createController(this.layoutService, { ignoreFocusOut: () => !this.configurationService.getValue('workbench.quickOpen.closeOnFocusLost'), - backKeybindingLabel: () => this.keybindingService.lookupKeybinding('workbench.action.quickInputBack')?.getLabel() || undefined + backKeybindingLabel: () => this.keybindingService.lookupKeybinding('workbench.action.quickInputBack')?.getLabel() || undefined, + hoverDelegate: this.hoverDelegate }); } } +class QuickInputHoverDelegate implements IHoverDelegate { + private lastHoverHideTime = 0; + readonly placement = 'element'; + + get delay() { + if (Date.now() - this.lastHoverHideTime < 200) { + return 0; // show instantly when a hover was recently shown + } + + return this.configurationService.getValue('workbench.hover.delay'); + } + + constructor( + private readonly configurationService: IConfigurationService, + private readonly hoverService: IHoverService + ) { } + + showHover(options: IHoverDelegateOptions, focus?: boolean): IHoverWidget | undefined { + return this.hoverService.showHover({ + ...options, + hideOnHover: false, + hideOnKeyDown: false, + skipFadeInAnimation: true, + }, focus); + } + + onDidHideHover(): void { + this.lastHoverHideTime = Date.now(); + } +} + registerSingleton(IQuickInputService, QuickInputService, InstantiationType.Delayed); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index f3bc012c9d8..cbae6e46829 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -165,6 +165,7 @@ import { IUserDataProfileService } from 'vs/workbench/services/userDataProfile/c import { EnablementState, IExtensionManagementServer, IScannedExtension, IWebExtensionsScannerService, IWorkbenchExtensionEnablementService, IWorkbenchExtensionManagementService } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; import { InstallVSIXOptions, ILocalExtension, IGalleryExtension, InstallOptions, IExtensionIdentifier, UninstallOptions, IExtensionsControlManifest, IGalleryMetadata, IExtensionManagementParticipant, Metadata } from 'vs/platform/extensionManagement/common/extensionManagement'; import { Codicon } from 'vs/base/common/codicons'; +import { IHoverOptions, IHoverService, IHoverWidget } from 'vs/workbench/services/hover/browser/hover'; import { IRemoteExtensionsScannerService } from 'vs/platform/remote/common/remoteExtensionsScanner'; export function createFileEditorInput(instantiationService: IInstantiationService, resource: URI): FileEditorInput { @@ -317,7 +318,8 @@ export function workbenchInstantiationService( instantiationService.stub(ICodeEditorService, disposables.add(new CodeEditorService(editorService, themeService, configService))); instantiationService.stub(IPaneCompositePartService, new TestPaneCompositeService()); instantiationService.stub(IListService, new TestListService()); - instantiationService.stub(IQuickInputService, disposables.add(new QuickInputService(configService, instantiationService, keybindingService, contextKeyService, themeService, accessibilityService, layoutService))); + const hoverService = instantiationService.stub(IHoverService, instantiationService.createInstance(TestHoverService)); + instantiationService.stub(IQuickInputService, disposables.add(new QuickInputService(configService, instantiationService, keybindingService, contextKeyService, themeService, accessibilityService, layoutService, hoverService))); instantiationService.stub(IWorkspacesService, new TestWorkspacesService()); instantiationService.stub(IWorkspaceTrustManagementService, new TestWorkspaceTrustManagementService()); instantiationService.stub(ITerminalInstanceService, new TestTerminalInstanceService()); @@ -730,6 +732,24 @@ export class TestSideBarPart implements IPaneCompositePart { layout(width: number, height: number, top: number, left: number): void { } } +class TestHoverService implements IHoverService { + private currentHover: IHoverWidget | undefined; + _serviceBrand: undefined; + showHover(options: IHoverOptions, focus?: boolean | undefined): IHoverWidget | undefined { + this.currentHover = new class implements IHoverWidget { + private _isDisposed = false; + get isDisposed(): boolean { return this._isDisposed; } + dispose(): void { + this._isDisposed = true; + } + }; + return this.currentHover; + } + hideHover(): void { + this.currentHover?.dispose(); + } +} + export class TestPanelPart implements IPaneCompositePart, IPaneCompositeSelectorPart { declare readonly _serviceBrand: undefined; diff --git a/src/vscode-dts/vscode.proposed.quickPickItemTooltip.d.ts b/src/vscode-dts/vscode.proposed.quickPickItemTooltip.d.ts new file mode 100644 index 00000000000..4e7d00fa5ed --- /dev/null +++ b/src/vscode-dts/vscode.proposed.quickPickItemTooltip.d.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/73904 + + export interface QuickPickItem { + /** + * An optional flag to sort the final results by index of first query match in label. Defaults to true. + */ + tooltip?: string | MarkdownString; + } +}