hovers: allow nested hovers (#289747)

* hovers: allow nested hovers

Feels a bit bizarre but it has a good use case in the agents sessions
view where the hover is more interactive than usual. Previously,
hovering any hoverable element within the hover would close the hover.
Now, hovering a hoverable element within a hover will open a nested
hover by default (capped at 3 levels.) It seems to work well.

* make hovers not disappear when hovering inside nested element

* tests

---------

Co-authored-by: BeniBenj <besimmonds@microsoft.com>
This commit is contained in:
Connor Peet
2026-01-23 12:13:21 -08:00
committed by GitHub
parent abf64deb34
commit 095e6b1f73
3 changed files with 829 additions and 63 deletions

View File

@@ -10,7 +10,7 @@ import { IHoverService } from './hover.js';
import { IContextMenuService } from '../../contextview/browser/contextView.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { HoverWidget } from './hoverWidget.js';
import { IContextViewProvider, IDelegate } from '../../../base/browser/ui/contextview/contextview.js';
import { ContextView, ContextViewDOMPosition, IDelegate } from '../../../base/browser/ui/contextview/contextview.js';
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { addDisposableListener, EventType, getActiveElement, isAncestorOfActiveElement, isAncestor, getWindow, isHTMLElement, isEditableElement } from '../../../base/browser/dom.js';
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
@@ -19,7 +19,6 @@ import { ResultKind } from '../../keybinding/common/keybindingResolver.js';
import { IAccessibilityService } from '../../accessibility/common/accessibility.js';
import { ILayoutService } from '../../layout/browser/layoutService.js';
import { mainWindow } from '../../../base/browser/window.js';
import { ContextViewHandler } from '../../contextview/browser/contextViewService.js';
import { HoverStyle, isManagedHoverTooltipMarkdownString, type IHoverLifecycleOptions, type IHoverOptions, type IHoverTarget, type IHoverWidget, type IManagedHover, type IManagedHoverContentOrFactory, type IManagedHoverOptions } from '../../../base/browser/ui/hover/hover.js';
import type { IHoverDelegate, IHoverDelegateTarget } from '../../../base/browser/ui/hover/hoverDelegate.js';
import { ManagedHoverWidget } from './updatableHoverWidget.js';
@@ -31,22 +30,77 @@ import { KeybindingsRegistry, KeybindingWeight } from '../../keybinding/common/k
import { IMarkdownString } from '../../../base/common/htmlContent.js';
import { stripIcons } from '../../../base/common/iconLabels.js';
/**
* Maximum nesting depth for hovers. This prevents runaway nesting.
*/
const MAX_HOVER_NESTING_DEPTH = 3;
/**
* An entry in the hover stack, representing a single hover and its associated state.
*/
interface IHoverStackEntry {
readonly hover: HoverWidget;
readonly options: IHoverOptions;
readonly contextView: ContextView;
readonly lastFocusedElementBeforeOpen: HTMLElement | undefined;
}
/**
* Result of creating a hover, containing the hover widget and associated state.
*/
interface ICreateHoverResult {
readonly hover: HoverWidget;
readonly store: DisposableStore;
readonly lastFocusedElementBeforeOpen: HTMLElement | undefined;
}
export class HoverService extends Disposable implements IHoverService {
declare readonly _serviceBrand: undefined;
private _contextViewHandler: IContextViewProvider;
private _currentHoverOptions: IHoverOptions | undefined;
private _currentHover: HoverWidget | undefined;
/**
* Stack of currently visible hovers. The last entry is the topmost hover.
* This enables nested hovers where hovering inside a hover can show another hover.
*/
private readonly _hoverStack: IHoverStackEntry[] = [];
private _currentDelayedHover: HoverWidget | undefined;
private _currentDelayedHoverWasShown: boolean = false;
private _currentDelayedHoverGroupId: number | string | undefined;
private _lastHoverOptions: IHoverOptions | undefined;
private _lastFocusedElementBeforeOpen: HTMLElement | undefined;
private readonly _delayedHovers = new Map<HTMLElement, { show: (focus: boolean) => void }>();
private readonly _managedHovers = new Map<HTMLElement, IManagedHover>();
/**
* Gets the current (topmost) hover from the stack, if any.
*/
private get _currentHover(): HoverWidget | undefined {
return this._hoverStack.at(-1)?.hover;
}
/**
* Gets the current (topmost) hover options from the stack, if any.
*/
private get _currentHoverOptions(): IHoverOptions | undefined {
return this._hoverStack.at(-1)?.options;
}
/**
* Returns whether the target element is inside any of the hovers in the stack.
* If it is, returns the index of the containing hover, otherwise returns -1.
*/
private _getContainingHoverIndex(target: HTMLElement | IHoverTarget): number {
const targetElements = isHTMLElement(target) ? [target] : target.targetElements;
// Search from top of stack to bottom (most recent hover first)
for (let i = this._hoverStack.length - 1; i >= 0; i--) {
for (const targetElement of targetElements) {
if (isAncestor(targetElement, this._hoverStack[i].hover.domNode)) {
return i;
}
}
}
return -1;
}
constructor(
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@@ -58,7 +112,6 @@ export class HoverService extends Disposable implements IHoverService {
super();
this._register(contextMenuService.onDidShowContextMenu(() => this.hideHover()));
this._contextViewHandler = this._register(new ContextViewHandler(this._layoutService));
this._register(KeybindingsRegistry.registerCommandAndKeybindingRule({
id: 'workbench.action.showHover',
@@ -74,7 +127,7 @@ export class HoverService extends Disposable implements IHoverService {
return undefined;
}
this._showHover(hover, options, focus);
return hover;
return hover.hover;
}
showDelayedHover(
@@ -120,18 +173,18 @@ export class HoverService extends Disposable implements IHoverService {
return undefined;
}
this._currentDelayedHover = hover;
this._currentDelayedHover = hover.hover;
this._currentDelayedHoverWasShown = false;
this._currentDelayedHoverGroupId = lifecycleOptions?.groupId;
timeout(this._configurationService.getValue<number>('workbench.hover.delay')).then(() => {
if (hover && !hover.isDisposed) {
if (hover.hover && !hover.hover.isDisposed) {
this._currentDelayedHoverWasShown = true;
this._showHover(hover, options);
}
});
return hover;
return hover.hover;
}
setupDelayedHover(
@@ -190,37 +243,50 @@ export class HoverService extends Disposable implements IHoverService {
return store;
}
private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): HoverWidget | undefined {
private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): ICreateHoverResult | undefined {
this._currentDelayedHover = undefined;
if (options.content === '') {
return undefined;
}
if (this._currentHover?.isLocked) {
return undefined;
}
// Set `id` to default if it's undefined
if (options.id === undefined) {
options.id = getHoverIdFromContent(options.content);
}
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
return undefined;
// Check if the target is inside an existing hover (nesting scenario)
const containingHoverIndex = this._getContainingHoverIndex(options.target);
const isNesting = containingHoverIndex >= 0;
if (isNesting) {
// Check max nesting depth
if (this._hoverStack.length >= MAX_HOVER_NESTING_DEPTH) {
return undefined;
}
// When nesting, don't check if the parent is locked - we allow nested hovers inside locked parents
} else {
// Not nesting: check if current top-level hover is locked
if (this._currentHover?.isLocked) {
return undefined;
}
// Check if identity is the same as current hover
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
return undefined;
}
}
this._currentHoverOptions = options;
this._lastHoverOptions = options;
const trapFocus = options.trapFocus || this._accessibilityService.isScreenReaderOptimized();
const activeElement = getActiveElement();
let lastFocusedElementBeforeOpen: HTMLElement | undefined;
// HACK, remove this check when #189076 is fixed
if (!skipLastFocusedUpdate) {
if (trapFocus && activeElement) {
if (!activeElement.classList.contains('monaco-hover')) {
this._lastFocusedElementBeforeOpen = activeElement as HTMLElement;
lastFocusedElementBeforeOpen = activeElement as HTMLElement;
}
} else {
this._lastFocusedElementBeforeOpen = undefined;
}
}
@@ -239,39 +305,48 @@ export class HoverService extends Disposable implements IHoverService {
}
hover.onDispose(() => {
const hoverWasFocused = this._currentHover?.domNode && isAncestorOfActiveElement(this._currentHover.domNode);
if (hoverWasFocused) {
// Required to handle cases such as closing the hover with the escape key
this._lastFocusedElementBeforeOpen?.focus();
}
// Only clear the current options if it's the current hover, the current options help
// reduce flickering when the same hover is shown multiple times
if (getHoverOptionsIdentity(this._currentHoverOptions) === getHoverOptionsIdentity(options)) {
this.doHideHover();
// Pop this hover from the stack if it's still there
const stackIndex = this._hoverStack.findIndex(entry => entry.hover === hover);
if (stackIndex >= 0) {
const entry = this._hoverStack[stackIndex];
// Restore focus if this hover was focused
const hoverWasFocused = isAncestorOfActiveElement(hover.domNode);
if (hoverWasFocused && entry.lastFocusedElementBeforeOpen) {
entry.lastFocusedElementBeforeOpen.focus();
}
// Also dispose all nested hovers (hovers at higher indices in the stack)
// Dispose from end to avoid index shifting issues
while (this._hoverStack.length > stackIndex + 1) {
const nestedEntry = this._hoverStack.pop()!;
nestedEntry.contextView.dispose();
nestedEntry.hover.dispose();
}
// Remove this hover from stack and dispose its context view
this._hoverStack.splice(stackIndex, 1);
entry.contextView.dispose();
}
hoverDisposables.dispose();
}, undefined, hoverDisposables);
// Set the container explicitly to enable aux window support
if (!options.container) {
const targetElement = isHTMLElement(options.target) ? options.target : options.target.targetElements[0];
options.container = this._layoutService.getContainer(getWindow(targetElement));
}
hover.onRequestLayout(() => this._contextViewHandler.layout(), undefined, hoverDisposables);
if (options.persistence?.sticky) {
hoverDisposables.add(addDisposableListener(getWindow(options.container).document, EventType.MOUSE_DOWN, e => {
if (!isAncestor(e.target as HTMLElement, hover.domNode)) {
this.doHideHover();
this._hideHoverAndDescendants(hover);
}
}));
} else {
if ('targetElements' in options.target) {
for (const element of options.target.targetElements) {
hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this.hideHover()));
hoverDisposables.add(addDisposableListener(element, EventType.CLICK, () => this._hideHoverAndDescendants(hover)));
}
} else {
hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this.hideHover()));
hoverDisposables.add(addDisposableListener(options.target, EventType.CLICK, () => this._hideHoverAndDescendants(hover)));
}
const focusedElement = getActiveElement();
if (focusedElement) {
@@ -290,30 +365,111 @@ export class HoverService extends Disposable implements IHoverService {
hoverDisposables.add(toDisposable(() => observer.disconnect()));
}
this._currentHover = hover;
return hover;
return { hover, lastFocusedElementBeforeOpen, store: hoverDisposables };
}
private _showHover(hover: HoverWidget, options: IHoverOptions, focus?: boolean) {
this._contextViewHandler.showContextView(
new HoverContextViewDelegate(hover, focus),
options.container
);
private _showHover(result: ICreateHoverResult, options: IHoverOptions, focus?: boolean) {
const { hover, lastFocusedElementBeforeOpen, store } = result;
// Check if the target is inside an existing hover (nesting scenario)
const containingHoverIndex = this._getContainingHoverIndex(options.target);
const isNesting = containingHoverIndex >= 0;
// If not nesting, close all existing hovers first
if (!isNesting) {
this._hideAllHovers();
} else {
// When nesting, close any sibling hovers (hovers at the same level or deeper
// than the containing hover). This ensures hovers within the same container
// are exclusive.
for (let i = this._hoverStack.length - 1; i > containingHoverIndex; i--) {
this._hoverStack[i].hover.dispose();
}
this._hoverStack.length = containingHoverIndex + 1;
}
// When nesting, add the new hover's container to all parent hovers' mouse trackers.
// This makes the parent hovers treat the nested hover as part of themselves,
// so they won't close when the mouse moves into the nested hover.
if (isNesting) {
for (let i = 0; i <= containingHoverIndex; i++) {
store.add(this._hoverStack[i].hover.addMouseTrackingElement(hover.domNode));
}
}
// Create a new ContextView for this hover with higher z-index for nested hovers
const container = options.container ?? this._layoutService.getContainer(getWindow(isHTMLElement(options.target) ? options.target : options.target.targetElements[0]));
const contextView = new ContextView(container, ContextViewDOMPosition.ABSOLUTE);
// Push to stack
const stackEntry: IHoverStackEntry = {
hover,
options,
contextView,
lastFocusedElementBeforeOpen
};
this._hoverStack.push(stackEntry);
// Show the hover in its context view
const delegate = new HoverContextViewDelegate(hover, focus, this._hoverStack.length);
contextView.show(delegate);
// Set up layout handling
store.add(hover.onRequestLayout(() => contextView.layout()));
options.onDidShow?.();
}
hideHover(force?: boolean): void {
if ((!force && this._currentHover?.isLocked) || !this._currentHoverOptions) {
/**
* Hides a specific hover and all hovers nested inside it.
*/
private _hideHoverAndDescendants(hover: HoverWidget): void {
const stackIndex = this._hoverStack.findIndex(entry => entry.hover === hover);
if (stackIndex < 0) {
return;
}
// Dispose all hovers from this index onwards (including nested ones)
for (let i = this._hoverStack.length - 1; i >= stackIndex; i--) {
this._hoverStack[i].hover.dispose();
}
this._hoverStack.length = stackIndex;
}
/**
* Hides all hovers in the stack.
*/
private _hideAllHovers(): void {
for (let i = this._hoverStack.length - 1; i >= 0; i--) {
this._hoverStack[i].hover.dispose();
}
this._hoverStack.length = 0;
}
hideHover(force?: boolean): void {
if (this._hoverStack.length === 0) {
return;
}
// If not forcing and the topmost hover is locked, don't hide
if (!force && this._currentHover?.isLocked) {
return;
}
// Hide only the topmost hover (pop from stack)
this.doHideHover();
}
private doHideHover(): void {
this._currentHover = undefined;
this._currentHoverOptions = undefined;
this._contextViewHandler.hideContextView();
// Pop and dispose the topmost hover
const length = this._hoverStack.length;
this._hoverStack[length - 1]?.hover.dispose();
this._hoverStack.length = length - 1;
// After popping a nested hover, unlock the parent if it was locked due to nesting
// (Note: the parent may have been explicitly locked via sticky, so we only unlock
// if there are remaining hovers and they're not sticky)
// For simplicity, we don't auto-unlock here - the parent remains in its current lock state
}
private _intersectionChange(entries: IntersectionObserverEntry[], hover: IDisposable): void {
@@ -347,7 +503,10 @@ export class HoverService extends Disposable implements IHoverService {
private _keyDown(e: KeyboardEvent, hover: HoverWidget, hideOnKeyDown: boolean) {
if (e.key === 'Alt') {
hover.isLocked = true;
// Lock all hovers in the stack when Alt is pressed
for (const entry of this._hoverStack) {
entry.hover.isLocked = true;
}
return;
}
const event = new StandardKeyboardEvent(e);
@@ -356,18 +515,28 @@ export class HoverService extends Disposable implements IHoverService {
return;
}
if (hideOnKeyDown && (!this._currentHoverOptions?.trapFocus || e.key !== 'Tab')) {
this.hideHover();
this._lastFocusedElementBeforeOpen?.focus();
// Find the entry for this hover to get its lastFocusedElementBeforeOpen
const stackEntry = this._hoverStack.find(entry => entry.hover === hover);
this._hideHoverAndDescendants(hover);
stackEntry?.lastFocusedElementBeforeOpen?.focus();
}
}
private _keyUp(e: KeyboardEvent, hover: HoverWidget) {
if (e.key === 'Alt') {
hover.isLocked = false;
// Hide if alt is released while the mouse is not over hover/target
if (!hover.isMouseIn) {
this.hideHover();
this._lastFocusedElementBeforeOpen?.focus();
// Unlock all hovers in the stack when Alt is released
for (const entry of this._hoverStack) {
// Only unlock if not sticky
if (!entry.options.persistence?.sticky) {
entry.hover.isLocked = false;
}
}
// Hide all hovers if the mouse is not over any of them
const anyMouseIn = this._hoverStack.some(entry => entry.hover.isMouseIn);
if (!anyMouseIn) {
const topEntry = this._hoverStack[this._hoverStack.length - 1];
this._hideAllHovers();
topEntry?.lastFocusedElementBeforeOpen?.focus();
}
}
}
@@ -572,8 +741,8 @@ function setupNativeHover(targetElement: HTMLElement, content: IManagedHoverCont
class HoverContextViewDelegate implements IDelegate {
// Render over all other context views
public readonly layer = 1;
// Render over all other context views, with higher layers for nested hovers
public readonly layer: number;
get anchorPosition() {
return this._hover.anchor;
@@ -581,8 +750,11 @@ class HoverContextViewDelegate implements IDelegate {
constructor(
private readonly _hover: HoverWidget,
private readonly _focus: boolean = false
private readonly _focus: boolean = false,
stackDepth: number = 1
) {
// Base layer is 1, nested hovers get higher layers
this.layer = stackDepth;
}
render(container: HTMLElement) {

View File

@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import './hover.css';
import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';
import { Event, Emitter } from '../../../base/common/event.js';
import * as dom from '../../../base/browser/dom.js';
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
@@ -93,6 +93,16 @@ export class HoverWidget extends Widget implements IHoverWidget {
this._hoverContainer.classList.toggle('locked', this._isLocked);
}
/**
* Adds an element to be tracked by this hover's mouse tracker. Mouse events on
* this element will be considered as being "inside" the hover, preventing it
* from closing. This is used for nested hovers where the child hover's container
* should be treated as part of the parent hover.
*/
addMouseTrackingElement(element: HTMLElement): IDisposable {
return this._lockMouseTracker.addElement(element);
}
constructor(
options: IHoverOptions,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@@ -688,6 +698,27 @@ class CompositeMouseTracker extends Widget {
this._onMouseOut.fire();
}
}
/**
* Adds an element to be tracked by this mouse tracker. Mouse events on this
* element will be considered as being "inside" the tracked area.
*/
addElement(element: HTMLElement): IDisposable {
if (this._elements.includes(element)) {
return Disposable.None;
}
this._elements.push(element);
const store = new DisposableStore();
store.add(dom.addDisposableListener(element, dom.EventType.MOUSE_OVER, () => this._onTargetMouseOver()));
store.add(dom.addDisposableListener(element, dom.EventType.MOUSE_LEAVE, () => this._onTargetMouseLeave()));
store.add(toDisposable(() => {
const index = this._elements.indexOf(element);
if (index >= 0) {
this._elements.splice(index, 1);
}
}));
return store;
}
}
class ElementHoverTarget implements IHoverTarget {

View File

@@ -0,0 +1,563 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { Event } from '../../../../base/common/event.js';
import { toDisposable } from '../../../../base/common/lifecycle.js';
import { timeout } from '../../../../base/common/async.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
import { IConfigurationService } from '../../../configuration/common/configuration.js';
import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js';
import { HoverService } from '../../browser/hoverService.js';
import { HoverWidget } from '../../browser/hoverWidget.js';
import { IContextMenuService } from '../../../contextview/browser/contextView.js';
import { IKeybindingService } from '../../../keybinding/common/keybinding.js';
import { ILayoutService } from '../../../layout/browser/layoutService.js';
import { IAccessibilityService } from '../../../accessibility/common/accessibility.js';
import { TestAccessibilityService } from '../../../accessibility/test/common/testAccessibilityService.js';
import { mainWindow } from '../../../../base/browser/window.js';
import { NoMatchingKb } from '../../../keybinding/common/keybindingResolver.js';
import { IMarkdownRendererService } from '../../../markdown/browser/markdownRenderer.js';
import type { IHoverWidget } from '../../../../base/browser/ui/hover/hover.js';
suite('HoverService', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
let hoverService: HoverService;
let fixture: HTMLElement;
let instantiationService: TestInstantiationService;
setup(() => {
fixture = document.createElement('div');
mainWindow.document.body.appendChild(fixture);
store.add(toDisposable(() => fixture.remove()));
instantiationService = store.add(new TestInstantiationService());
const configurationService = new TestConfigurationService();
configurationService.setUserConfiguration('workbench.hover.delay', 0);
instantiationService.stub(IConfigurationService, configurationService);
instantiationService.stub(IContextMenuService, {
onDidShowContextMenu: Event.None
});
instantiationService.stub(IKeybindingService, {
mightProducePrintableCharacter() { return false; },
softDispatch() { return NoMatchingKb; },
resolveKeyboardEvent() {
return {
getLabel() { return ''; },
getAriaLabel() { return ''; },
getElectronAccelerator() { return null; },
getUserSettingsLabel() { return null; },
isWYSIWYG() { return false; },
hasMultipleChords() { return false; },
getDispatchChords() { return [null]; },
getSingleModifierDispatchChords() { return []; },
getChords() { return []; }
};
}
});
instantiationService.stub(ILayoutService, {
activeContainer: fixture,
mainContainer: fixture,
getContainer() { return fixture; },
onDidLayoutContainer: Event.None
});
instantiationService.stub(IAccessibilityService, new TestAccessibilityService());
instantiationService.stub(IMarkdownRendererService, {
render() { return { element: document.createElement('div'), dispose() { } }; },
setDefaultCodeBlockRenderer() { }
});
hoverService = store.add(instantiationService.createInstance(HoverService));
});
// #region Helper functions
function createTarget(): HTMLElement {
const target = document.createElement('div');
target.style.width = '100px';
target.style.height = '100px';
fixture.appendChild(target);
return target;
}
function showHover(content: string, target?: HTMLElement, options?: Partial<Parameters<typeof hoverService.showInstantHover>[0]>): IHoverWidget {
const hover = hoverService.showInstantHover({
content,
target: target ?? createTarget(),
...options
});
assert.ok(hover, `Hover with content "${content}" should be created`);
return hover;
}
function asHoverWidget(hover: IHoverWidget): HoverWidget {
return hover as HoverWidget;
}
/**
* Checks if a hover's DOM node is present in the document.
*/
function isInDOM(hover: IHoverWidget): boolean {
return mainWindow.document.body.contains(asHoverWidget(hover).domNode);
}
/**
* Asserts that a hover is in the DOM.
*/
function assertInDOM(hover: IHoverWidget, message?: string): void {
assert.ok(isInDOM(hover), message ?? 'Hover should be in the DOM');
}
/**
* Asserts that a hover is NOT in the DOM.
*/
function assertNotInDOM(hover: IHoverWidget, message?: string): void {
assert.ok(!isInDOM(hover), message ?? 'Hover should not be in the DOM');
}
/**
* Creates a nested hover by appending a target element inside the parent hover's DOM.
*/
function createNestedHover(parentHover: IHoverWidget, content: string): IHoverWidget {
const nestedTarget = document.createElement('div');
asHoverWidget(parentHover).domNode.appendChild(nestedTarget);
return showHover(content, nestedTarget);
}
/**
* Creates a chain of nested hovers up to the specified depth.
* Returns the array of hovers from outermost to innermost.
*/
function createHoverChain(depth: number): HoverWidget[] {
const hovers: HoverWidget[] = [];
let currentTarget: HTMLElement = createTarget();
for (let i = 0; i < depth; i++) {
const hover = hoverService.showInstantHover({
content: `Hover ${i + 1}`,
target: currentTarget
});
if (!hover) {
break;
}
hovers.push(asHoverWidget(hover));
currentTarget = document.createElement('div');
asHoverWidget(hover).domNode.appendChild(currentTarget);
}
return hovers;
}
function disposeHovers(hovers: HoverWidget[]): void {
for (const h of [...hovers].reverse()) {
h?.dispose();
}
}
// #endregion
suite('showInstantHover', () => {
test('should not show hover with empty content', () => {
const target = createTarget();
const hover = hoverService.showInstantHover({
content: '',
target
});
assert.strictEqual(hover, undefined, 'Hover should not be created for empty content');
});
test('should call onDidShow callback when hover is shown', () => {
const target = createTarget();
let didShowCalled = false;
const hover = hoverService.showInstantHover({
content: 'Test',
target,
onDidShow: () => { didShowCalled = true; }
});
assert.ok(didShowCalled, 'onDidShow should be called');
assert.ok(hover);
assertInDOM(hover, 'Hover should be in DOM after showing');
hover.dispose();
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
});
test('should deduplicate hovers by id', () => {
const target = createTarget();
const hover1 = hoverService.showInstantHover({
content: 'Same content',
target,
id: 'same-id'
});
const hover2 = hoverService.showInstantHover({
content: 'Same content',
target,
id: 'same-id'
});
assert.ok(hover1, 'First hover should be created');
assertInDOM(hover1, 'First hover should be in DOM');
assert.strictEqual(hover2, undefined, 'Second hover with same id should not be created');
// Different id should create new hover
const hover3 = hoverService.showInstantHover({
content: 'Content 3',
target,
id: 'different-id'
});
assert.ok(hover3, 'Hover with different id should be created');
assertInDOM(hover3, 'Third hover should be in DOM');
hover1?.dispose();
hover3?.dispose();
});
test('should apply additional classes to hover DOM', () => {
const hover = showHover('Test', undefined, {
additionalClasses: ['custom-class-1', 'custom-class-2']
});
const domNode = asHoverWidget(hover).domNode;
assertInDOM(hover, 'Hover should be in DOM');
assert.ok(domNode.classList.contains('custom-class-1'), 'Should have custom-class-1');
assert.ok(domNode.classList.contains('custom-class-2'), 'Should have custom-class-2');
hover.dispose();
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
});
});
suite('hideHover', () => {
test('should hide non-locked hover', () => {
const hover = showHover('Test');
assertInDOM(hover, 'Hover should be in DOM initially');
hoverService.hideHover();
assert.strictEqual(hover.isDisposed, true, 'Hover should be disposed after hideHover');
assertNotInDOM(hover, 'Hover should be removed from DOM after hideHover');
});
test('should not hide locked hover without force flag', () => {
const hover = showHover('Test', undefined, {
persistence: { sticky: true }
});
assertInDOM(hover, 'Locked hover should be in DOM');
hoverService.hideHover();
assert.strictEqual(hover.isDisposed, false, 'Locked hover should not be disposed without force');
assertInDOM(hover, 'Locked hover should remain in DOM');
hoverService.hideHover(true);
assert.strictEqual(hover.isDisposed, true, 'Locked hover should be disposed with force=true');
assertNotInDOM(hover, 'Locked hover should be removed from DOM with force');
});
});
suite('nested hovers', () => {
test('should keep parent hover visible when nested hover is created', () => {
const parentHover = showHover('Parent');
assertInDOM(parentHover, 'Parent hover should be in DOM');
const nestedHover = createNestedHover(parentHover, 'Nested');
assertInDOM(nestedHover, 'Nested hover should be in DOM');
assertInDOM(parentHover, 'Parent hover should still be in DOM after nested hover created');
assert.strictEqual(parentHover.isDisposed, false, 'Parent hover should remain visible');
assert.strictEqual(nestedHover.isDisposed, false, 'Nested hover should be visible');
nestedHover.dispose();
assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM after dispose');
assertInDOM(parentHover, 'Parent hover should remain in DOM after nested is disposed');
parentHover.dispose();
assertNotInDOM(parentHover, 'Parent hover should be removed from DOM after dispose');
});
test('should dispose nested hover when parent is disposed', () => {
const parentHover = showHover('Parent');
const nestedHover = createNestedHover(parentHover, 'Nested');
assertInDOM(parentHover, 'Parent hover should be in DOM');
assertInDOM(nestedHover, 'Nested hover should be in DOM');
parentHover.dispose();
assert.strictEqual(nestedHover.isDisposed, true, 'Nested hover should be disposed when parent is disposed');
assertNotInDOM(parentHover, 'Parent hover should be removed from DOM');
assertNotInDOM(nestedHover, 'Nested hover should be removed from DOM when parent is disposed');
});
test('should dispose entire hover chain when root is disposed', () => {
const hovers = createHoverChain(3);
assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');
// Verify all hovers are in DOM
for (let i = 0; i < hovers.length; i++) {
assert.ok(mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be in DOM`);
}
// Dispose the root hover
hovers[0].dispose();
// All hovers in the chain should be disposed and removed from DOM
for (let i = 0; i < hovers.length; i++) {
assert.strictEqual(hovers[i].isDisposed, true, `Hover ${i + 1} should be disposed`);
assert.ok(!mainWindow.document.body.contains(hovers[i].domNode), `Hover ${i + 1} should be removed from DOM`);
}
});
test('should dispose only nested hovers when middle hover is disposed', () => {
const hovers = createHoverChain(3);
assert.strictEqual(hovers.length, 3, 'Should create 3 hovers');
// Verify all hovers are in DOM
for (const h of hovers) {
assert.ok(mainWindow.document.body.contains(h.domNode), 'All hovers should be in DOM initially');
}
// Dispose the middle hover
hovers[1].dispose();
assert.strictEqual(hovers[0].isDisposed, false, 'Root hover should remain');
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Root hover should remain in DOM');
assert.strictEqual(hovers[1].isDisposed, true, 'Middle hover should be disposed');
assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Middle hover should be removed from DOM');
assert.strictEqual(hovers[2].isDisposed, true, 'Innermost hover should be disposed');
assert.ok(!mainWindow.document.body.contains(hovers[2].domNode), 'Innermost hover should be removed from DOM');
hovers[0].dispose();
});
test('should enforce maximum nesting depth', () => {
// Create hovers up to the max depth (3)
const hovers = createHoverChain(3);
assert.strictEqual(hovers.length, 3, 'Should create exactly 3 hovers (max depth)');
// Verify all 3 hovers are in DOM
for (const h of hovers) {
assert.ok(mainWindow.document.body.contains(h.domNode), 'Hover should be in DOM');
}
// Try to create a 4th nested hover - should fail
const nestedTarget = document.createElement('div');
hovers[2].domNode.appendChild(nestedTarget);
const fourthHover = hoverService.showInstantHover({
content: 'Hover 4',
target: nestedTarget
});
assert.strictEqual(fourthHover, undefined, 'Fourth hover should not be created due to max nesting depth');
disposeHovers(hovers);
});
test('should allow new hover chain after disposing previous chain', () => {
// Create and dispose a chain
const firstChain = createHoverChain(3);
for (const h of firstChain) {
assert.ok(mainWindow.document.body.contains(h.domNode), 'First chain hover should be in DOM');
}
disposeHovers(firstChain);
for (const h of firstChain) {
assert.ok(!mainWindow.document.body.contains(h.domNode), 'First chain hover should be removed from DOM');
}
// Should be able to create a new chain
const secondChain = createHoverChain(3);
assert.strictEqual(secondChain.length, 3, 'Should create new chain after disposing previous');
for (const h of secondChain) {
assert.ok(mainWindow.document.body.contains(h.domNode), 'Second chain hover should be in DOM');
}
disposeHovers(secondChain);
});
test('hideHover should close innermost hover first', () => {
const hovers = createHoverChain(2);
// Verify both are in DOM
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be in DOM');
assert.ok(mainWindow.document.body.contains(hovers[1].domNode), 'Inner hover should be in DOM');
hoverService.hideHover();
// Innermost hover should be disposed and removed from DOM
assert.strictEqual(hovers[1].isDisposed, true, 'Innermost hover should be disposed');
assert.ok(!mainWindow.document.body.contains(hovers[1].domNode), 'Innermost hover should be removed from DOM');
assert.strictEqual(hovers[0].isDisposed, false, 'Outer hover should remain');
assert.ok(mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should remain in DOM');
hoverService.hideHover();
assert.strictEqual(hovers[0].isDisposed, true, 'Outer hover should be disposed on second call');
assert.ok(!mainWindow.document.body.contains(hovers[0].domNode), 'Outer hover should be removed from DOM');
});
});
suite('setupDelayedHover', () => {
test('should evaluate function options on mouseover', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const target = createTarget();
let callCount = 0;
const disposable = hoverService.setupDelayedHover(target, () => {
callCount++;
return { content: `Call ${callCount}` };
});
// First mouseover
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
assert.strictEqual(callCount, 1, 'Options function should be called on first mouseover');
await timeout(0);
hoverService.hideHover(true);
// Second mouseover should call function again
target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
assert.strictEqual(callCount, 2, 'Options function should be called on second mouseover');
await timeout(0);
disposable.dispose();
hoverService.hideHover(true);
}));
});
suite('setupManagedHover', () => {
test('should use native title attribute when showNativeHover is true', () => {
const target = createTarget();
const hover = hoverService.setupManagedHover(
{ showHover: () => undefined, delay: 0, showNativeHover: true },
target,
'Native hover content'
);
assert.strictEqual(target.getAttribute('title'), 'Native hover content');
hover.dispose();
assert.strictEqual(target.getAttribute('title'), null, 'Title should be removed on dispose');
});
test('should update content dynamically', async () => {
const target = createTarget();
const hover = hoverService.setupManagedHover(
{ showHover: () => undefined, delay: 0, showNativeHover: true },
target,
'Initial'
);
assert.strictEqual(target.getAttribute('title'), 'Initial');
await hover.update('Updated');
assert.strictEqual(target.getAttribute('title'), 'Updated');
await hover.update('Final');
assert.strictEqual(target.getAttribute('title'), 'Final');
hover.dispose();
});
});
suite('showDelayedHover', () => {
test('should reject hover when current hover is locked and target is outside', () => {
const lockedHover = showHover('Locked', undefined, {
persistence: { sticky: true }
});
assertInDOM(lockedHover, 'Locked hover should be in DOM');
const otherTarget = createTarget();
const rejectedHover = hoverService.showDelayedHover({
content: 'Should not show',
target: otherTarget
}, {});
assert.strictEqual(rejectedHover, undefined, 'Should reject hover when locked hover exists');
assertInDOM(lockedHover, 'Locked hover should remain in DOM after rejection');
lockedHover.dispose();
assertNotInDOM(lockedHover, 'Locked hover should be removed from DOM after dispose');
});
});
suite('hover locking', () => {
test('isLocked should be settable on hover widget', () => {
const hover = showHover('Test');
const widget = asHoverWidget(hover);
assertInDOM(hover, 'Hover should be in DOM');
assert.strictEqual(widget.isLocked, false, 'Should not be locked initially');
widget.isLocked = true;
assert.strictEqual(widget.isLocked, true, 'Should be locked after setting');
assertInDOM(hover, 'Hover should remain in DOM after locking');
widget.isLocked = false;
assert.strictEqual(widget.isLocked, false, 'Should be unlocked after unsetting');
hover.dispose();
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
});
test('sticky option should set isLocked to true', () => {
const hover = showHover('Test', undefined, {
persistence: { sticky: true }
});
assertInDOM(hover, 'Sticky hover should be in DOM');
assert.strictEqual(asHoverWidget(hover).isLocked, true, 'Should be locked when sticky');
hover.dispose();
assertNotInDOM(hover, 'Sticky hover should be removed from DOM after dispose');
});
});
suite('showAndFocusLastHover', () => {
test('should recreate last disposed hover', () => {
const target = createTarget();
const hover = hoverService.showInstantHover({
content: 'Remember me',
target
});
assert.ok(hover);
assertInDOM(hover, 'Initial hover should be in DOM');
hover.dispose();
assertNotInDOM(hover, 'Hover should be removed from DOM after dispose');
// Should recreate the hover - verify a new hover is shown
hoverService.showAndFocusLastHover();
// Verify there is a hover in the DOM (it's a new hover instance)
// eslint-disable-next-line no-restricted-syntax
const hoverElements = mainWindow.document.querySelectorAll('.monaco-hover');
assert.ok(hoverElements.length > 0, 'A hover should be recreated and in the DOM');
// Clean up
hoverService.hideHover(true);
// Verify cleanup
// eslint-disable-next-line no-restricted-syntax
const remainingHovers = mainWindow.document.querySelectorAll('.monaco-hover');
assert.strictEqual(remainingHovers.length, 0, 'No hovers should remain in DOM after cleanup');
});
});
});