@theia/core 1.75.0 → 1.76.0-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 Safi Seid-Ahmad, K2view and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { enableJSDOM } from './test/jsdom';
18
+ let disableJSDOM = enableJSDOM();
19
+
20
+ import { Container } from 'inversify';
21
+ import { expect } from 'chai';
22
+ import { HoverService } from './hover-service';
23
+ import { PreferenceService } from '../common';
24
+ import { CoreMarkdownRenderer } from './markdown-rendering/markdown-renderer';
25
+ import { OpenerService } from './opener-service';
26
+
27
+ disableJSDOM();
28
+
29
+ /* eslint-disable @typescript-eslint/no-explicit-any */
30
+
31
+ describe('HoverService', () => {
32
+ let container: Container;
33
+ let hoverService: HoverService;
34
+ let originalMatches: (selectors: string) => boolean;
35
+
36
+ before(() => {
37
+ disableJSDOM = enableJSDOM();
38
+ // The hover service positions its host after waiting for an animation frame.
39
+ // JSDOM (without pretendToBeVisual) does not provide requestAnimationFrame.
40
+ (global as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 0);
41
+ // JSDOM implements neither the Popover API (showPopover/hidePopover) nor the
42
+ // `:popover-open` pseudo-class: stub them, tracking the open state in an attribute.
43
+ const elementPrototype = window.HTMLElement.prototype as any;
44
+ elementPrototype.showPopover = function (this: HTMLElement): void { this.setAttribute('data-test-popover-open', 'true'); };
45
+ elementPrototype.hidePopover = function (this: HTMLElement): void { this.removeAttribute('data-test-popover-open'); };
46
+ originalMatches = elementPrototype.matches;
47
+ elementPrototype.matches = function (this: HTMLElement, selectors: string): boolean {
48
+ return selectors === ':popover-open' ? this.hasAttribute('data-test-popover-open') : originalMatches.call(this, selectors);
49
+ };
50
+ });
51
+
52
+ after(() => {
53
+ const elementPrototype = window.HTMLElement.prototype as any;
54
+ delete elementPrototype.showPopover;
55
+ delete elementPrototype.hidePopover;
56
+ elementPrototype.matches = originalMatches;
57
+ delete (global as any).requestAnimationFrame;
58
+ disableJSDOM();
59
+ });
60
+
61
+ beforeEach(() => {
62
+ container = new Container();
63
+ container.bind(HoverService).toSelf().inSingletonScope();
64
+ container.bind(PreferenceService).toConstantValue({ get: () => 0 } as any);
65
+ container.bind(CoreMarkdownRenderer).toConstantValue({ render: () => ({ element: document.createElement('div'), dispose: () => { } }) } as any);
66
+ container.bind(OpenerService).toConstantValue({} as any);
67
+ hoverService = container.get(HoverService);
68
+ });
69
+
70
+ afterEach(() => {
71
+ hoverService.cancelHover();
72
+ });
73
+
74
+ function waitForHover(): Promise<void> {
75
+ // hover delay (0ms timeout) + animation frame (0ms timeout stub)
76
+ return new Promise(resolve => setTimeout(resolve, 20));
77
+ }
78
+
79
+ function waitForMouseOutDismissal(): Promise<void> {
80
+ // the mouse-out handler re-checks the hover state after quickMouseThresholdMillis (200ms)
81
+ return new Promise(resolve => setTimeout(resolve, 250));
82
+ }
83
+
84
+ interface FakeSecondaryWindow {
85
+ secondaryDocument: Document;
86
+ fireEvent(type: string): void;
87
+ }
88
+
89
+ /**
90
+ * Creates a document simulating one hosted in a secondary window: unlike a document from
91
+ * `createHTMLDocument`, it has a `defaultView` window on which the hover service can listen
92
+ * for the window going away.
93
+ */
94
+ function createSecondaryWindowDocument(options?: { closed?: boolean }): FakeSecondaryWindow {
95
+ const secondaryDocument = document.implementation.createHTMLDocument('secondary window');
96
+ const listeners = new Map<string, EventListener[]>();
97
+ const fakeWindow = {
98
+ closed: options?.closed ?? false,
99
+ requestAnimationFrame: (cb: FrameRequestCallback) => setTimeout(cb, 0),
100
+ addEventListener: (type: string, listener: EventListener) => {
101
+ const forType = listeners.get(type) ?? [];
102
+ forType.push(listener);
103
+ listeners.set(type, forType);
104
+ },
105
+ removeEventListener: (type: string, listener: EventListener) => {
106
+ const forType = listeners.get(type);
107
+ const index = forType?.indexOf(listener) ?? -1;
108
+ if (forType && index > -1) {
109
+ forType.splice(index, 1);
110
+ }
111
+ }
112
+ };
113
+ Object.defineProperty(secondaryDocument, 'defaultView', { value: fakeWindow, configurable: true });
114
+ return {
115
+ secondaryDocument,
116
+ fireEvent: type => [...(listeners.get(type) ?? [])].forEach(listener => listener({ type } as Event))
117
+ };
118
+ }
119
+
120
+ it('renders the hover in the document of the target element', async () => {
121
+ const target = document.createElement('div');
122
+ document.body.appendChild(target);
123
+ hoverService.requestHover({ content: 'main window hover', target, position: 'right', skipHoverDelay: true });
124
+ await waitForHover();
125
+ expect(document.querySelector('.theia-hover'), 'hover should be in the main document').to.exist;
126
+ target.remove();
127
+ });
128
+
129
+ it('renders the hover in a secondary window document if the target lives there', async () => {
130
+ const { secondaryDocument } = createSecondaryWindowDocument();
131
+ const target = secondaryDocument.createElement('div');
132
+ secondaryDocument.body.appendChild(target);
133
+ hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
134
+ await waitForHover();
135
+ expect(document.querySelector('.theia-hover'), 'hover should not be in the main document').to.not.exist;
136
+ expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be in the secondary document').to.exist;
137
+ target.remove();
138
+ });
139
+
140
+ it('creates the hover host in the document of the target instead of adopting it across documents', async () => {
141
+ const mainTarget = document.createElement('div');
142
+ document.body.appendChild(mainTarget);
143
+ hoverService.requestHover({ content: 'main', target: mainTarget, position: 'right', skipHoverDelay: true });
144
+ await waitForHover();
145
+ const mainHost = document.querySelector('.theia-hover');
146
+ expect(mainHost, 'hover should be in the main document').to.exist;
147
+
148
+ const { secondaryDocument } = createSecondaryWindowDocument();
149
+ const secondaryTarget = secondaryDocument.createElement('div');
150
+ secondaryDocument.body.appendChild(secondaryTarget);
151
+ hoverService.requestHover({ content: 'secondary', target: secondaryTarget, position: 'right', skipHoverDelay: true });
152
+ await waitForHover();
153
+ const secondaryHost = secondaryDocument.querySelector('.theia-hover');
154
+ expect(secondaryHost, 'hover should be in the secondary document').to.exist;
155
+ // moving a host into another document would make it outlive its window; a host must be
156
+ // created in the document it is shown in
157
+ expect(secondaryHost, 'the secondary host must not be the adopted main host').to.not.equal(mainHost);
158
+ expect(secondaryHost!.ownerDocument).to.equal(secondaryDocument);
159
+ mainTarget.remove();
160
+ secondaryTarget.remove();
161
+ });
162
+
163
+ it('cancels the hover when the window hosting it is closed', async () => {
164
+ const { secondaryDocument, fireEvent } = createSecondaryWindowDocument();
165
+ const target = secondaryDocument.createElement('div');
166
+ secondaryDocument.body.appendChild(target);
167
+ hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
168
+ await waitForHover();
169
+ expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be in the secondary document').to.exist;
170
+
171
+ fireEvent('pagehide');
172
+ expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be removed when its window closes').to.not.exist;
173
+
174
+ // hovers in the main window must keep working afterwards
175
+ const mainTarget = document.createElement('div');
176
+ document.body.appendChild(mainTarget);
177
+ hoverService.requestHover({ content: 'after window close', target: mainTarget, position: 'right', skipHoverDelay: true });
178
+ await waitForHover();
179
+ expect(document.querySelector('.theia-hover'), 'hover should be rendered in the main document afterwards').to.exist;
180
+ target.remove();
181
+ mainTarget.remove();
182
+ });
183
+
184
+ it('dismisses the hover when the pointer leaves its target in a secondary window', async () => {
185
+ const { secondaryDocument } = createSecondaryWindowDocument();
186
+ const target = secondaryDocument.createElement('div');
187
+ secondaryDocument.body.appendChild(target);
188
+ hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
189
+ await waitForHover();
190
+ expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be shown in the secondary document').to.exist;
191
+
192
+ target.dispatchEvent(new window.Event('mouseout'));
193
+ await waitForMouseOutDismissal();
194
+ expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be dismissed after the pointer left its target').to.not.exist;
195
+ target.remove();
196
+ });
197
+
198
+ it('dismisses a non-interactive hover on mousedown in a secondary window', async () => {
199
+ const { secondaryDocument } = createSecondaryWindowDocument();
200
+ const target = secondaryDocument.createElement('div');
201
+ secondaryDocument.body.appendChild(target);
202
+ hoverService.requestHover({ content: 'secondary window hover', target, position: 'right', skipHoverDelay: true });
203
+ await waitForHover();
204
+ expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be shown in the secondary document').to.exist;
205
+
206
+ secondaryDocument.body.dispatchEvent(new window.Event('mousedown'));
207
+ expect(secondaryDocument.querySelector('.theia-hover'), 'hover should be dismissed on mousedown outside of it').to.not.exist;
208
+ target.remove();
209
+ });
210
+
211
+ it('shows at most one hover box in a secondary window across repeated hovers', async () => {
212
+ const { secondaryDocument } = createSecondaryWindowDocument();
213
+ const target = secondaryDocument.createElement('div');
214
+ secondaryDocument.body.appendChild(target);
215
+ for (let i = 0; i < 2; i++) {
216
+ hoverService.requestHover({ content: `hover ${i}`, target, position: 'right', skipHoverDelay: true });
217
+ await waitForHover();
218
+ target.dispatchEvent(new window.Event('mouseout'));
219
+ await waitForMouseOutDismissal();
220
+ }
221
+ hoverService.requestHover({ content: 'final hover', target, position: 'right', skipHoverDelay: true });
222
+ await waitForHover();
223
+ expect(secondaryDocument.querySelectorAll('.theia-hover').length, 'stale hover hosts must not pile up').to.equal(1);
224
+ target.remove();
225
+ });
226
+
227
+ it('does not render a hover for a target in an already closed window', async () => {
228
+ const { secondaryDocument } = createSecondaryWindowDocument({ closed: true });
229
+ const target = secondaryDocument.createElement('div');
230
+ secondaryDocument.body.appendChild(target);
231
+ hoverService.requestHover({ content: 'closed window hover', target, position: 'right', skipHoverDelay: true });
232
+ await waitForHover();
233
+ expect(secondaryDocument.querySelector('.theia-hover'), 'no hover should be rendered in a closed window').to.not.exist;
234
+ expect(document.querySelector('.theia-hover'), 'no hover should be rendered in the main document either').to.not.exist;
235
+ target.remove();
236
+ });
237
+
238
+ it('keeps the hover host hidden until it has been positioned', async () => {
239
+ // the host is appended (and the popover shown) at (0, 0) first and only positioned after an
240
+ // animation frame; it must not be hittable in the meantime: a visible popover at (0, 0) can
241
+ // cover the target, kick it out of the hover chain and retrigger mouseenter hovers in an
242
+ // endless show/hide loop (e.g. for tabs at the top-left corner of a secondary window)
243
+ const target = document.createElement('div');
244
+ document.body.appendChild(target);
245
+ const rendering = (hoverService as any).renderHover({ content: 'positioning', target, position: 'right' }) as Promise<void>;
246
+ const host = document.querySelector('.theia-hover') as HTMLElement;
247
+ expect(host, 'hover should be appended synchronously').to.exist;
248
+ expect(host.style.visibility, 'hover must not be visible before it has been positioned').to.equal('hidden');
249
+ await rendering;
250
+ expect(host.style.visibility, 'hover should be revealed once positioned').to.not.equal('hidden');
251
+ target.remove();
252
+ });
253
+
254
+ it('does not reveal a hover that was superseded while waiting to be positioned', async () => {
255
+ const target = document.createElement('div');
256
+ document.body.appendChild(target);
257
+ const service = hoverService as any;
258
+ const first = service.renderHover({ content: 'first', target, position: 'right' }) as Promise<void>;
259
+ const second = service.renderHover({ content: 'second', target, position: 'right' }) as Promise<void>;
260
+ await first;
261
+ const host = document.querySelector('.theia-hover') as HTMLElement;
262
+ expect(host.style.visibility, 'the superseded render must not reveal the host').to.equal('hidden');
263
+ await second;
264
+ expect(host.style.visibility, 'the latest render reveals the host').to.not.equal('hidden');
265
+ target.remove();
266
+ });
267
+
268
+ it('does not leak css classes from a hover that was superseded while waiting to be positioned', async () => {
269
+ const service = hoverService as any;
270
+ // keep the first render stuck waiting for its animation frame so that a second hover supersedes it mid-render
271
+ const originalAnimationFrame = service.hostAnimationFrame.bind(service);
272
+ let releaseFirst: () => void;
273
+ let animationFrameCalls = 0;
274
+ service.hostAnimationFrame = (element: HTMLElement) => ++animationFrameCalls === 1
275
+ ? new Promise<void>(resolve => { releaseFirst = resolve; })
276
+ : originalAnimationFrame(element);
277
+ const target = document.createElement('div');
278
+ document.body.appendChild(target);
279
+ hoverService.requestHover({ content: 'first', target, position: 'right', skipHoverDelay: true, cssClasses: ['first-hover-class'] });
280
+ await waitForHover();
281
+ hoverService.requestHover({ content: 'second', target, position: 'right', skipHoverDelay: true });
282
+ await waitForHover();
283
+ releaseFirst!(); // let the superseded render finish
284
+ await waitForHover();
285
+ const host = document.querySelector('.theia-hover');
286
+ expect(host, 'second hover should be rendered').to.exist;
287
+ expect(host!.classList.contains('first-hover-class'), 'the superseded hover must not leak its css classes').to.equal(false);
288
+ target.remove();
289
+ });
290
+
291
+ it('recovers if the open hover can no longer be hidden', async () => {
292
+ // simulate a hover whose document is no longer fully active, e.g. because the secondary
293
+ // window hosting it was closed: hidePopover throws and must not break subsequent hovers
294
+ const target = document.createElement('div');
295
+ document.body.appendChild(target);
296
+ hoverService.requestHover({ content: 'first', target, position: 'right', skipHoverDelay: true });
297
+ await waitForHover();
298
+ const host = document.querySelector('.theia-hover') as HTMLElement;
299
+ expect(host, 'first hover should be rendered').to.exist;
300
+ (host as any).hidePopover = () => { throw new Error('InvalidStateError: not fully active'); };
301
+
302
+ const secondTarget = document.createElement('div');
303
+ document.body.appendChild(secondTarget);
304
+ hoverService.requestHover({ content: 'second', target: secondTarget, position: 'right', skipHoverDelay: true });
305
+ await waitForHover();
306
+ const secondHost = document.querySelector('.theia-hover');
307
+ expect(secondHost, 'hover should be rendered again in the main document').to.exist;
308
+ expect(secondHost!.textContent).to.equal('second');
309
+ target.remove();
310
+ secondTarget.remove();
311
+ });
312
+
313
+ describe('position fallback', () => {
314
+ // simulated window: 400px wide, 600px high
315
+ const windowWidth = 400;
316
+ const windowHeight = 600;
317
+ let target: HTMLElement;
318
+ let originalBodyRect: () => DOMRect;
319
+
320
+ function rect(left: number, top: number, width: number, height: number): DOMRect {
321
+ return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => '' };
322
+ }
323
+
324
+ beforeEach(() => {
325
+ target = document.createElement('div');
326
+ document.body.appendChild(target);
327
+ const host: HTMLElement = (hoverService as any).hoverHost;
328
+ host.getBoundingClientRect = () => rect(0, 0, 300, 50);
329
+ originalBodyRect = document.body.getBoundingClientRect.bind(document.body);
330
+ document.body.getBoundingClientRect = () => rect(0, 0, windowWidth, windowHeight);
331
+ Object.defineProperty(document.documentElement, 'scrollHeight', { value: windowHeight, configurable: true });
332
+ });
333
+
334
+ afterEach(() => {
335
+ document.body.getBoundingClientRect = originalBodyRect;
336
+ delete (document.documentElement as any).scrollHeight;
337
+ target.remove();
338
+ });
339
+
340
+ function setHostPosition(position: 'left' | 'right' | 'top' | 'bottom'): string {
341
+ const service = hoverService as any;
342
+ return service.setHostPosition(target, service.hoverHost, position);
343
+ }
344
+
345
+ it('keeps the requested position when the hover fits', () => {
346
+ target.getBoundingClientRect = () => rect(320, 100, 60, 20); // plenty of room on the left
347
+ expect(setHostPosition('left')).to.equal('left');
348
+ });
349
+
350
+ it('falls back to bottom when a left hover fits on neither side of a full-width target', () => {
351
+ target.getBoundingClientRect = () => rect(0, 100, windowWidth, 20);
352
+ expect(setHostPosition('left')).to.equal('bottom');
353
+ });
354
+
355
+ it('falls back to top when the full-width target is near the bottom of the window', () => {
356
+ target.getBoundingClientRect = () => rect(0, windowHeight - 30, windowWidth, 20);
357
+ expect(setHostPosition('right')).to.equal('top');
358
+ });
359
+
360
+ it('keeps the requested direction when the perpendicular direction does not fit either', () => {
361
+ const host: HTMLElement = (hoverService as any).hoverHost;
362
+ host.getBoundingClientRect = () => rect(0, 0, 300, windowHeight); // hover as tall as the window
363
+ target.getBoundingClientRect = () => rect(0, 100, windowWidth, 20);
364
+ expect(setHostPosition('left')).to.equal('right');
365
+ });
366
+ });
367
+ });
@@ -51,6 +51,22 @@ export namespace HoverPosition {
51
51
  }
52
52
  return position;
53
53
  }
54
+
55
+ /**
56
+ * Tests whether a hover of the given dimensions fits next to its target in the given
57
+ * position without extending beyond the window bounds.
58
+ */
59
+ export function fits(position: HoverPosition, target: DOMRect, host: DOMRect, totalWidth: number, totalHeight: number): boolean {
60
+ if (position === 'left') {
61
+ return target.left - host.width - 5 >= 0;
62
+ } else if (position === 'right') {
63
+ return target.right + host.width + 5 <= totalWidth;
64
+ } else if (position === 'top') {
65
+ return target.top - host.height - 5 >= 0;
66
+ } else {
67
+ return target.bottom + host.height + 5 <= totalHeight;
68
+ }
69
+ }
54
70
  }
55
71
 
56
72
  export interface HoverRequest {
@@ -102,9 +118,25 @@ export class HoverService {
102
118
  @inject(OpenerService) protected readonly openerService: OpenerService;
103
119
 
104
120
  protected _hoverHost: HTMLElement | undefined;
121
+ /**
122
+ * The host of the current hover, which may live in a secondary window's document.
123
+ * Resolving against the main document here instead would silently replace the host
124
+ * whenever the current hover lives in another document, detaching the dismissal
125
+ * listeners and `unRenderHover` from the host that is actually rendered.
126
+ */
105
127
  protected get hoverHost(): HTMLElement {
106
- if (!this._hoverHost) {
107
- this._hoverHost = document.createElement('div');
128
+ return this._hoverHost ?? this.getOrCreateHoverHost(document);
129
+ }
130
+
131
+ /**
132
+ * Returns the host element to render hovers into for the given document, creating it if the
133
+ * current one belongs to a different document. A host is always created in the document it is
134
+ * shown in: adopting a host into another document would let it outlive its window, and touching
135
+ * it after that window was closed breaks (and in Electron crashes) the application.
136
+ */
137
+ protected getOrCreateHoverHost(targetDocument: Document): HTMLElement {
138
+ if (!this._hoverHost || this._hoverHost.ownerDocument !== targetDocument) {
139
+ this._hoverHost = targetDocument.createElement('div');
108
140
  this._hoverHost.classList.add(HoverService.hostClassName);
109
141
  this._hoverHost.style.position = 'absolute';
110
142
  this._hoverHost.setAttribute('popover', 'hint');
@@ -113,18 +145,44 @@ export class HoverService {
113
145
  }
114
146
  protected pendingTimeout: Disposable | undefined;
115
147
  protected hoverTarget: HTMLElement | undefined;
148
+ /** Identifies the latest render so that superseded renders can detect they are stale. */
149
+ protected renderSequence = 0;
116
150
  protected lastHidHover = Date.now();
117
151
  protected readonly disposeOnHide = new DisposableCollection();
118
152
 
119
153
  requestHover(request: HoverRequest): void {
120
154
  this.cancelHover();
155
+ const targetWindow = request.target.ownerDocument.defaultView;
156
+ if (!targetWindow || targetWindow.closed) {
157
+ // the window hosting the target is already gone, e.g. a closed secondary window:
158
+ // its document must not be touched anymore
159
+ return;
160
+ }
121
161
  const delay = request.skipHoverDelay ? 0 : this.getHoverDelay();
122
162
  this.pendingTimeout = disposableTimeout(() => this.renderHover(request), delay);
123
163
  this.hoverTarget = request.target;
164
+ // resolve the host for the target's document up front so that the listeners below attach to the host that will be rendered
165
+ this.getOrCreateHoverHost(request.target.ownerDocument);
166
+ this.listenForWindowClose(request.target);
124
167
  this.listenForMouseOut();
125
168
  this.listenForMouseClick(request);
126
169
  }
127
170
 
171
+ /**
172
+ * Cancels the hover when the window hosting the target element goes away, e.g. when the
173
+ * secondary window containing the target is closed: neither the hover host nor any listeners
174
+ * may outlive the document they belong to.
175
+ */
176
+ protected listenForWindowClose(target: HTMLElement): void {
177
+ const targetWindow = target.ownerDocument.defaultView;
178
+ if (!targetWindow || targetWindow === window) {
179
+ return;
180
+ }
181
+ const handlePageHide = () => this.cancelHover();
182
+ targetWindow.addEventListener('pagehide', handlePageHide);
183
+ this.disposeOnHide.push({ dispose: () => targetWindow.removeEventListener('pagehide', handlePageHide) });
184
+ }
185
+
128
186
  protected getHoverDelay(): number {
129
187
  return Date.now() - this.lastHidHover < quickMouseThresholdMillis
130
188
  ? 0
@@ -132,9 +190,16 @@ export class HoverService {
132
190
  }
133
191
 
134
192
  protected async renderHover(request: HoverRequest): Promise<void> {
135
- const host = this.hoverHost;
136
- let firstChild: HTMLElement | undefined;
137
193
  const { target, content, position, cssClasses, interactive, onHide } = request;
194
+ const targetWindow = target.ownerDocument.defaultView;
195
+ if (!targetWindow || targetWindow.closed) {
196
+ // the window hosting the target is already gone, e.g. a closed secondary window:
197
+ // its document must not be touched anymore
198
+ return;
199
+ }
200
+ const host = this.getOrCreateHoverHost(target.ownerDocument);
201
+ const sequence = ++this.renderSequence;
202
+ let firstChild: HTMLElement | undefined;
138
203
  if (onHide) {
139
204
  this.disposeOnHide.push({ dispose: onHide.bind(request) });
140
205
  }
@@ -163,7 +228,9 @@ export class HoverService {
163
228
  // handler then cancels the hover, and the cycle repeats: the tooltip flickers and never settles.
164
229
  // `visibility: hidden` still lays the host out (so it can be measured) but is not hit-tested.
165
230
  host.style.visibility = 'hidden';
166
- document.body.append(host);
231
+ // Render the hover in the document of the target: it may be hosted in a secondary window,
232
+ // whose coordinate space is unrelated to the main window's.
233
+ target.ownerDocument.body.append(host);
167
234
  if (!host.matches(':popover-open')) {
168
235
  host.showPopover();
169
236
  }
@@ -188,7 +255,12 @@ export class HoverService {
188
255
  }
189
256
  }
190
257
 
191
- await animationFrame(); // Allow the browser to size the host
258
+ await this.hostAnimationFrame(target); // Allow the browser to size the host
259
+ if (sequence !== this.renderSequence || !host.isConnected) {
260
+ // this hover was cancelled or superseded by a newer one while waiting for the animation
261
+ // frame: it must neither reposition nor reveal the host
262
+ return;
263
+ }
192
264
  const updatedPosition = this.setHostPosition(target, host, position);
193
265
  // Reveal the host only once it sits at its final position, so it never overlaps the target while
194
266
  // parked at (0,0). Dropping the declaration rather than assigning a value hands `visibility` back
@@ -206,14 +278,40 @@ export class HoverService {
206
278
  });
207
279
  }
208
280
 
281
+ /**
282
+ * Waits for an animation frame in the window hosting the given target element,
283
+ * which may be a secondary window whose rendering is independent of the main window's.
284
+ */
285
+ protected hostAnimationFrame(target: HTMLElement): Promise<void> {
286
+ const targetWindow = target.ownerDocument.defaultView;
287
+ if (!targetWindow || targetWindow === window) {
288
+ return animationFrame();
289
+ }
290
+ return new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve()));
291
+ }
292
+
209
293
  protected setHostPosition(target: HTMLElement, host: HTMLElement, position: HoverPosition): HoverPosition {
294
+ const hostDocument = target.ownerDocument;
210
295
  const targetDimensions = target.getBoundingClientRect();
211
296
  const hostDimensions = host.getBoundingClientRect();
212
- const documentWidth = document.body.getBoundingClientRect().width;
297
+ const documentWidth = hostDocument.body.getBoundingClientRect().width;
213
298
  // document.body.getBoundingClientRect().height doesn't work as expected
214
299
  // scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777
215
- const documentHeight = document.documentElement.scrollHeight;
300
+ const documentHeight = hostDocument.documentElement.scrollHeight;
216
301
  position = HoverPosition.invertIfNecessary(position, targetDimensions, hostDimensions, documentWidth, documentHeight);
302
+ if (!HoverPosition.fits(position, targetDimensions, hostDimensions, documentWidth, documentHeight)) {
303
+ // The hover fits on neither side of the target in the requested direction, e.g. when the
304
+ // target spans the full width of a narrow secondary window. Try the perpendicular direction
305
+ // so that the target and the hover both remain visible. If that does not fit either,
306
+ // keep the requested direction; the clamping below keeps the hover inside the viewport.
307
+ const fallback = HoverPosition.invertIfNecessary(
308
+ position === 'left' || position === 'right' ? 'bottom' : 'right',
309
+ targetDimensions, hostDimensions, documentWidth, documentHeight
310
+ );
311
+ if (HoverPosition.fits(fallback, targetDimensions, hostDimensions, documentWidth, documentHeight)) {
312
+ position = fallback;
313
+ }
314
+ }
217
315
  if (position === 'top' || position === 'bottom') {
218
316
  const targetMiddleWidth = targetDimensions.left + (targetDimensions.width / 2);
219
317
  const middleAlignment = targetMiddleWidth - (hostDimensions.width / 2);
@@ -230,9 +328,13 @@ export class HoverService {
230
328
  const middleAlignment = targetMiddleHeight - (hostDimensions.height / 2);
231
329
  const furthestTop = Math.min(documentHeight - hostDimensions.height, middleAlignment);
232
330
  const top = Math.max(0, furthestTop);
233
- const left = position === 'left'
331
+ const desiredLeft = position === 'left'
234
332
  ? targetDimensions.left - hostDimensions.width - 5
235
333
  : targetDimensions.right + 5;
334
+ // the hover may not fit on either side of the target, e.g. when the target
335
+ // spans the full width of a narrow (secondary) window: keep it in the viewport
336
+ const furthestRight = Math.min(documentWidth - hostDimensions.width, desiredLeft);
337
+ const left = Math.max(0, furthestRight);
236
338
  host.style.setProperty('--theia-hover-before-position', `${targetMiddleHeight - top - 5}px`);
237
339
  host.style.left = `${left}px`;
238
340
  host.style.top = `${top}px`;
@@ -280,18 +382,43 @@ export class HoverService {
280
382
  this.cancelHover();
281
383
  }
282
384
  };
283
- document.addEventListener('mousedown', handleMouseDown, true);
284
- this.disposeOnHide.push({ dispose: () => document.removeEventListener('mousedown', handleMouseDown, true) });
385
+ // Listen in the document of the target, which may be hosted in a secondary window
386
+ const targetDocument = request.target.ownerDocument;
387
+ targetDocument.addEventListener('mousedown', handleMouseDown, true);
388
+ this.disposeOnHide.push({ dispose: () => targetDocument.removeEventListener('mousedown', handleMouseDown, true) });
285
389
  }
286
390
 
287
391
  protected unRenderHover(): void {
288
- if (this.hoverHost.matches(':popover-open')) {
289
- this.hoverHost.hidePopover();
392
+ const host = this._hoverHost;
393
+ if (!host) {
394
+ return;
395
+ }
396
+ const hostWindow = host.ownerDocument.defaultView;
397
+ if (!hostWindow || hostWindow.closed) {
398
+ // the window hosting the hover is already gone, e.g. a closed secondary window:
399
+ // its DOM must not be touched anymore; abandon the host and start from scratch
400
+ this._hoverHost = undefined;
401
+ return;
290
402
  }
291
- this.hoverHost.remove();
292
- this.hoverHost.replaceChildren();
403
+ try {
404
+ if (host.matches(':popover-open')) {
405
+ host.hidePopover();
406
+ }
407
+ } catch {
408
+ // hidePopover throws for popovers in documents that are no longer fully active,
409
+ // e.g. while the secondary window hosting the hover is being closed
410
+ }
411
+ host.remove();
412
+ host.replaceChildren();
413
+ // drop the classes added for the rendered hover (position and request classes): a render
414
+ // aborted because it was superseded must not leak its classes into the next hover
415
+ host.className = HoverService.hostClassName;
293
416
  // The host is reused across hovers; drop the transient hidden state set during measurement so a
294
417
  // hover cancelled before it was revealed does not leave the next one invisible.
295
- this.hoverHost.style.removeProperty('visibility');
418
+ host.style.removeProperty('visibility');
419
+ if (host.ownerDocument !== document) {
420
+ // never keep a host from another document: it must not outlive its window
421
+ this._hoverHost = undefined;
422
+ }
296
423
  }
297
424
  }