@theia/core 1.77.0-next.4 → 1.77.0-next.43

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.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/lib/browser/catalog.json +207 -22
  3. package/lib/browser/color-application-contribution.d.ts +6 -0
  4. package/lib/browser/color-application-contribution.d.ts.map +1 -1
  5. package/lib/browser/color-application-contribution.js +15 -4
  6. package/lib/browser/color-application-contribution.js.map +1 -1
  7. package/lib/browser/color-application-contribution.spec.d.ts +2 -0
  8. package/lib/browser/color-application-contribution.spec.d.ts.map +1 -0
  9. package/lib/browser/color-application-contribution.spec.js +96 -0
  10. package/lib/browser/color-application-contribution.spec.js.map +1 -0
  11. package/lib/browser/color-registry.d.ts +6 -0
  12. package/lib/browser/color-registry.d.ts.map +1 -1
  13. package/lib/browser/color-registry.js +12 -0
  14. package/lib/browser/color-registry.js.map +1 -1
  15. package/lib/browser/tree/tree-selection-state.d.ts +23 -1
  16. package/lib/browser/tree/tree-selection-state.d.ts.map +1 -1
  17. package/lib/browser/tree/tree-selection-state.js +23 -1
  18. package/lib/browser/tree/tree-selection-state.js.map +1 -1
  19. package/lib/browser/tree/tree-widget.d.ts +28 -0
  20. package/lib/browser/tree/tree-widget.d.ts.map +1 -1
  21. package/lib/browser/tree/tree-widget.js +59 -3
  22. package/lib/browser/tree/tree-widget.js.map +1 -1
  23. package/lib/browser/tree/tree-widget.spec.js +53 -3
  24. package/lib/browser/tree/tree-widget.spec.js.map +1 -1
  25. package/lib/browser/widgets/select-component.d.ts.map +1 -1
  26. package/lib/browser/widgets/select-component.js +6 -11
  27. package/lib/browser/widgets/select-component.js.map +1 -1
  28. package/lib/browser/widgets/select-component.spec.d.ts +2 -0
  29. package/lib/browser/widgets/select-component.spec.d.ts.map +1 -0
  30. package/lib/browser/widgets/select-component.spec.js +66 -0
  31. package/lib/browser/widgets/select-component.spec.js.map +1 -0
  32. package/lib/common/core-preferences.js +5 -5
  33. package/lib/common/core-preferences.js.map +1 -1
  34. package/lib/node/messaging/binary-message-pipe.d.ts.map +1 -1
  35. package/lib/node/messaging/binary-message-pipe.js +5 -3
  36. package/lib/node/messaging/binary-message-pipe.js.map +1 -1
  37. package/lib/node/messaging/binary-message-pipe.spec.d.ts +2 -0
  38. package/lib/node/messaging/binary-message-pipe.spec.d.ts.map +1 -0
  39. package/lib/node/messaging/binary-message-pipe.spec.js +71 -0
  40. package/lib/node/messaging/binary-message-pipe.spec.js.map +1 -0
  41. package/package.json +16 -16
  42. package/src/browser/color-application-contribution.spec.ts +108 -0
  43. package/src/browser/color-application-contribution.ts +16 -4
  44. package/src/browser/color-registry.ts +13 -0
  45. package/src/browser/style/hover-service.css +0 -2
  46. package/src/browser/tree/tree-selection-state.ts +40 -1
  47. package/src/browser/tree/tree-widget.spec.tsx +59 -3
  48. package/src/browser/tree/tree-widget.tsx +64 -2
  49. package/src/browser/widgets/select-component.spec.ts +77 -0
  50. package/src/browser/widgets/select-component.tsx +6 -10
  51. package/src/common/core-preferences.ts +5 -5
  52. package/src/node/messaging/binary-message-pipe.spec.ts +81 -0
  53. package/src/node/messaging/binary-message-pipe.ts +5 -4
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@theia/core",
3
- "version": "1.77.0-next.4+4f24ee401",
3
+ "version": "1.77.0-next.43+5dbb84a9f",
4
4
  "description": "Theia is a cloud & desktop IDE framework implemented in TypeScript.",
5
5
  "main": "lib/common/index.js",
6
6
  "typings": "lib/common/index.d.ts",
7
7
  "dependencies": {
8
- "@babel/runtime": "^7.29.7",
8
+ "@babel/runtime": "^7.29.10",
9
9
  "@lumino/algorithm": "^2.0.5",
10
10
  "@lumino/commands": "^2.3.4",
11
11
  "@lumino/coreutils": "^2.2.3",
@@ -17,8 +17,8 @@
17
17
  "@lumino/virtualdom": "^2.0.5",
18
18
  "@lumino/widgets": "2.7.5",
19
19
  "@parcel/watcher": "^2.6.0",
20
- "@theia/application-package": "1.77.0-next.4+4f24ee401",
21
- "@theia/request": "1.77.0-next.4+4f24ee401",
20
+ "@theia/application-package": "1.77.0-next.43+5dbb84a9f",
21
+ "@theia/request": "1.77.0-next.43+5dbb84a9f",
22
22
  "@types/body-parser": "^1.19.6",
23
23
  "@types/express": "^4.17.25",
24
24
  "@types/fs-extra": "^4.0.15",
@@ -28,16 +28,16 @@
28
28
  "@types/markdown-it-emoji": "^3.0.1",
29
29
  "@types/route-parser": "^0.1.7",
30
30
  "@types/safer-buffer": "^2.1.3",
31
- "@types/ws": "^8.18.1",
31
+ "@types/ws": "^8.18.2",
32
32
  "@types/yargs": "^17.0.35",
33
33
  "@vscode/codicons": "0.0.45",
34
34
  "ajv": "^6.15.0",
35
35
  "async-mutex": "^0.4.1",
36
36
  "body-parser": "^1.20.8",
37
37
  "cookie": "^1.1.1",
38
- "dompurify": "^3.4.15",
38
+ "dompurify": "^3.4.16",
39
39
  "drivelist": "^12.0.2",
40
- "express": "^4.22.2",
40
+ "express": "^4.22.3",
41
41
  "fast-json-stable-stringify": "^2.1.0",
42
42
  "file-icons-js": "~1.0.3",
43
43
  "font-awesome": "^4.7.0",
@@ -51,7 +51,7 @@
51
51
  "keytar": "7.9.0",
52
52
  "lodash.debounce": "^4.0.8",
53
53
  "lodash.throttle": "^4.1.1",
54
- "markdown-it": "^14.3.1",
54
+ "markdown-it": "^14.3.2",
55
55
  "markdown-it-anchor": "^9.2.1",
56
56
  "markdown-it-emoji": "^3.1.0",
57
57
  "msgpackr": "^1.12.1",
@@ -62,13 +62,13 @@
62
62
  "reflect-metadata": "^0.2.2",
63
63
  "route-parser": "^0.0.5",
64
64
  "safer-buffer": "^2.1.2",
65
- "socket.io": "^4.8.3",
66
- "socket.io-client": "^4.8.3",
65
+ "socket.io": "^4.8.4",
66
+ "socket.io-client": "^4.8.4",
67
67
  "tslib": "^2.8.1",
68
68
  "uuid": "^11.1.1",
69
69
  "vscode-languageserver-protocol": "3.17.5",
70
70
  "vscode-uri": "3.0.8",
71
- "ws": "^8.21.3",
71
+ "ws": "^8.22.0",
72
72
  "yargs": "^17.7.3"
73
73
  },
74
74
  "peerDependencies": {
@@ -223,15 +223,15 @@
223
223
  "devDependencies": {
224
224
  "@theia/ext-scripts": "1.76.0",
225
225
  "@theia/re-exports": "1.76.0",
226
- "@types/react": "^19.2.18",
227
- "@types/react-dom": "^19.2.7",
226
+ "@types/react": "^19.3.0",
227
+ "@types/react-dom": "^19.3.0",
228
228
  "minimist": "^1.2.8",
229
229
  "nodejs-file-downloader": "4.13.0",
230
- "react": "^19.2.8",
231
- "react-dom": "^19.2.8"
230
+ "react": "^19.3.0",
231
+ "react-dom": "^19.3.0"
232
232
  },
233
233
  "nyc": {
234
234
  "extends": "../../configs/nyc.json"
235
235
  },
236
- "gitHead": "4f24ee40105b8c53808c8293c41387304d15960c"
236
+ "gitHead": "5dbb84a9f43e9287fe3d6f0f52ee1dd2c3c4a20f"
237
237
  }
@@ -0,0 +1,108 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
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 { expect } from 'chai';
21
+ import './shell/application-shell'; // load first to avoid a circular import failure via `SecondaryWindowHandler`
22
+ import { ColorApplicationContribution } from './color-application-contribution';
23
+ import { ColorRegistry } from './color-registry';
24
+ import { ThemeService } from './theming';
25
+ disableJSDOM();
26
+
27
+ class TestColorRegistry extends ColorRegistry {
28
+
29
+ readonly values = new Map<string, string>();
30
+
31
+ override *getColors(): IterableIterator<string> {
32
+ yield* this.values.keys();
33
+ }
34
+
35
+ override getCurrentColor(id: string): string | undefined {
36
+ return this.values.get(id);
37
+ }
38
+ }
39
+
40
+ class TestColorApplicationContribution extends ColorApplicationContribution {
41
+
42
+ protected override readonly themeService = { getCurrentTheme: () => ({ type: 'dark' }) } as unknown as ThemeService;
43
+
44
+ constructor(protected override readonly colors: ColorRegistry) {
45
+ super();
46
+ }
47
+
48
+ updateWindowForTest(win: Window): void {
49
+ this.updateWindow(win);
50
+ }
51
+ }
52
+
53
+ describe('ColorApplicationContribution', () => {
54
+
55
+ let colors: TestColorRegistry;
56
+ let contribution: TestColorApplicationContribution;
57
+
58
+ beforeEach(() => {
59
+ disableJSDOM = enableJSDOM();
60
+ colors = new TestColorRegistry();
61
+ colors.values.set('editor.background', '#1e1e1e');
62
+ colors.values.set('scmGraph.historyItemHoverAdditionsForeground', '#487e02');
63
+ colors.values.set('unresolved.color', '');
64
+ contribution = new TestColorApplicationContribution(colors);
65
+ });
66
+
67
+ afterEach(() => {
68
+ disableJSDOM();
69
+ });
70
+
71
+ it('sets a --theia- and a --vscode- prefixed variable for every resolved color', () => {
72
+ contribution.updateWindowForTest(window);
73
+ const style = document.documentElement.style;
74
+ expect(style.getPropertyValue('--theia-editor-background')).to.equal('#1e1e1e');
75
+ expect(style.getPropertyValue('--vscode-editor-background')).to.equal('#1e1e1e');
76
+ expect(style.getPropertyValue('--theia-scmGraph-historyItemHoverAdditionsForeground')).to.equal('#487e02');
77
+ expect(style.getPropertyValue('--vscode-scmGraph-historyItemHoverAdditionsForeground')).to.equal('#487e02');
78
+ expect(style.length).to.equal(4);
79
+ });
80
+
81
+ it('does not set variables for colors without a value', () => {
82
+ contribution.updateWindowForTest(window);
83
+ const style = document.documentElement.style;
84
+ expect(style.getPropertyValue('--theia-unresolved-color')).to.equal('');
85
+ expect(style.getPropertyValue('--vscode-unresolved-color')).to.equal('');
86
+ });
87
+
88
+ it('overwrites the variables in place on a subsequent update', () => {
89
+ contribution.updateWindowForTest(window);
90
+ colors.values.set('editor.background', '#ffffff');
91
+ contribution.updateWindowForTest(window);
92
+ const style = document.documentElement.style;
93
+ expect(style.getPropertyValue('--theia-editor-background')).to.equal('#ffffff');
94
+ expect(style.getPropertyValue('--vscode-editor-background')).to.equal('#ffffff');
95
+ expect(style.length).to.equal(4);
96
+ });
97
+
98
+ it('removes both variables of a color that the current theme does not provide anymore', () => {
99
+ contribution.updateWindowForTest(window);
100
+ colors.values.delete('scmGraph.historyItemHoverAdditionsForeground');
101
+ contribution.updateWindowForTest(window);
102
+ const style = document.documentElement.style;
103
+ expect(style.getPropertyValue('--theia-scmGraph-historyItemHoverAdditionsForeground')).to.equal('');
104
+ expect(style.getPropertyValue('--vscode-scmGraph-historyItemHoverAdditionsForeground')).to.equal('');
105
+ expect(style.getPropertyValue('--theia-editor-background')).to.equal('#1e1e1e');
106
+ expect(style.length).to.equal(2);
107
+ });
108
+ });
@@ -73,6 +73,13 @@ export class ColorApplicationContribution implements FrontendApplicationContribu
73
73
  this.onDidChangeEmitter.fire();
74
74
  }
75
75
 
76
+ /**
77
+ * The CSS variables currently applied to the document element of a window. Used to only remove
78
+ * those that the current theme does not provide anymore, instead of removing and re-applying all
79
+ * of them on every update.
80
+ */
81
+ protected readonly appliedCssVariables = new WeakMap<Window, ReadonlySet<string>>();
82
+
76
83
  protected readonly toUpdate = new DisposableCollection();
77
84
  protected update(): void {
78
85
  this.toUpdate.dispose();
@@ -88,14 +95,19 @@ export class ColorApplicationContribution implements FrontendApplicationContribu
88
95
 
89
96
  const documentElement = win.document.documentElement;
90
97
  if (documentElement) {
98
+ const applied = new Set<string>();
91
99
  for (const id of this.colors.getColors()) {
92
- const variable = this.colors.getCurrentCssVariable(id);
93
- if (variable) {
94
- const { name, value } = variable;
100
+ for (const { name, value } of this.colors.getCurrentCssVariables(id)) {
95
101
  documentElement.style.setProperty(name, value);
96
- this.toUpdate.push(Disposable.create(() => documentElement.style.removeProperty(name)));
102
+ applied.add(name);
103
+ }
104
+ }
105
+ for (const name of this.appliedCssVariables.get(win) ?? []) {
106
+ if (!applied.has(name)) {
107
+ documentElement.style.removeProperty(name);
97
108
  }
98
109
  }
110
+ this.appliedCssVariables.set(win, applied);
99
111
  }
100
112
  }
101
113
 
@@ -39,6 +39,19 @@ export class ColorRegistry {
39
39
  return { name, value };
40
40
  }
41
41
 
42
+ /**
43
+ * All CSS variables to set for the given color: the primary one from {@link getCurrentCssVariable} and the
44
+ * `--vscode-` prefixed alias. VS Code extensions reference colors with the `--vscode-` prefix, e.g. in the
45
+ * inline styles of the HTML they render, and Monaco only defines those variables within its own containers.
46
+ */
47
+ getCurrentCssVariables(id: string): ColorCssVariable[] {
48
+ const variable = this.getCurrentCssVariable(id);
49
+ if (!variable) {
50
+ return [];
51
+ }
52
+ return [variable, { name: this.toCssVariableName(id, 'vscode'), value: variable.value }];
53
+ }
54
+
42
55
  toCssVariableName(id: string, prefix = 'theia'): string {
43
56
  return `--${prefix}-${id.replace(/\./g, '-')}`;
44
57
  }
@@ -31,8 +31,6 @@
31
31
  max-width: var(--theia-hover-max-width);
32
32
  overflow-wrap: break-word;
33
33
  user-select: text;
34
- --vscode-scmGraph-historyItemHoverAdditionsForeground: var(--theia-scmGraph-historyItemHoverAdditionsForeground);
35
- --vscode-scmGraph-historyItemHoverDeletionsForeground: var(--theia-scmGraph-historyItemHoverDeletionsForeground);
36
34
  }
37
35
 
38
36
  /* overwrite potentially different default user agent styles */
@@ -54,9 +54,48 @@ export namespace FocusableTreeSelection {
54
54
  */
55
55
  export class TreeSelectionState {
56
56
 
57
+ /**
58
+ * The stack of selections, by the id of the nodes they refer to.
59
+ *
60
+ * Everything this state does with a selection resolves the node through the tree anyway, so keeping the nodes
61
+ * themselves would only pin a subtree that a refresh has since replaced, along with whatever its elements refer
62
+ * to. Keeping the ids also lets a selection outlive its nodes briefly leaving the tree, as happens when a tree
63
+ * updates itself by clearing its contents before filling them in again.
64
+ */
65
+ protected readonly ids: ReadonlyArray<{
66
+ readonly node: string,
67
+ readonly focus?: string,
68
+ readonly type?: TreeSelection.SelectionType
69
+ }>;
70
+
57
71
  constructor(
58
72
  protected readonly tree: Tree,
59
- readonly selectionStack: ReadonlyArray<FocusableTreeSelection> = []) {
73
+ selectionStack: ReadonlyArray<FocusableTreeSelection> = []) {
74
+ this.ids = selectionStack.map(({ node, focus, type }) => ({ node: node.id, focus: focus?.id, type }));
75
+ }
76
+
77
+ /**
78
+ * The selections of this state that refer to a node the tree currently has. Selections of nodes that are not in
79
+ * the tree are left out, but are kept by this state, so that they apply again if their nodes come back.
80
+ */
81
+ get selectionStack(): ReadonlyArray<FocusableTreeSelection> {
82
+ const stack: FocusableTreeSelection[] = [];
83
+ for (const { node, focus, type } of this.ids) {
84
+ const selected = this.findSelectableTreeNode(node);
85
+ if (selected) {
86
+ stack.push({ node: selected, focus: this.findSelectableTreeNode(focus), type });
87
+ }
88
+ }
89
+ return stack;
90
+ }
91
+
92
+ /**
93
+ * Resolves a node id against the tree. Unlike {@link toSelectableTreeNode} this does not report an id that does
94
+ * not resolve, because the stack is expected to outlive the nodes it refers to.
95
+ */
96
+ protected findSelectableTreeNode(id: string | undefined): SelectableTreeNode | undefined {
97
+ const candidate = id === undefined ? undefined : this.tree.getNode(id);
98
+ return SelectableTreeNode.is(candidate) ? candidate : undefined;
60
99
  }
61
100
 
62
101
  nextState(selection: FocusableTreeSelection): TreeSelectionState {
@@ -38,6 +38,14 @@ class CountingView extends TreeWidget.View {
38
38
  this.scrollCount++;
39
39
  }
40
40
  };
41
+ /** Reports the list height the way Virtuoso does once it has measured the list. */
42
+ measure(height: number, renderedHeight = height): void {
43
+ this.setRenderedHeight(renderedHeight);
44
+ this.onTotalListHeightChanged(height);
45
+ }
46
+ setRenderedHeight(scrollHeight: number): void {
47
+ this.scroller = { scrollHeight } as HTMLElement;
48
+ }
41
49
  }
42
50
 
43
51
  class TestTreeWidget extends TreeWidget {
@@ -190,6 +198,8 @@ describe('TreeWidget', () => {
190
198
  let container: HTMLElement;
191
199
  let root: Root;
192
200
  let view: CountingView | undefined;
201
+ let frames: FrameRequestCallback[];
202
+ let requestAnimationFrame: typeof window.requestAnimationFrame;
193
203
  const rows: TreeWidget.NodeRow[] = [0, 1, 2].map(index => ({ index, depth: 0, node: leaf(`node-${index}`) }));
194
204
 
195
205
  const render = (props: Partial<TreeWidget.ViewProps>): void => flushSync(() => root.render(
@@ -202,20 +212,66 @@ describe('TreeWidget', () => {
202
212
  {...props}
203
213
  />
204
214
  ));
215
+ const runFrames = (): void => frames.splice(0).forEach(frame => frame(0));
216
+ /** Mount and measure the list, and serve the mount-time request. */
217
+ const mount = (props: Partial<TreeWidget.ViewProps>): void => {
218
+ render(props);
219
+ view!.measure(300);
220
+ runFrames();
221
+ };
205
222
 
206
223
  beforeEach(() => {
207
224
  container = document.createElement('div');
208
225
  document.body.appendChild(container);
209
226
  root = createRoot(container);
227
+ frames = [];
228
+ requestAnimationFrame = window.requestAnimationFrame;
229
+ window.requestAnimationFrame = frame => frames.push(frame);
210
230
  });
211
231
 
212
232
  afterEach(() => {
233
+ window.requestAnimationFrame = requestAnimationFrame;
213
234
  flushSync(() => root.unmount());
214
235
  document.body.removeChild(container);
215
236
  });
216
237
 
217
- it('does not scroll again when re-rendered without a new scroll request', () => {
238
+ it('holds a scroll request made before the list is measured until the list is rendered at its height', () => {
239
+ render({ scrollToRow: 2, scrollToRowRequestId: 1 });
240
+ expect(view!.scrollCount).to.equal(0);
241
+
242
+ view!.measure(300, 100);
243
+ runFrames();
244
+ expect(view!.scrollCount).to.equal(0);
245
+
246
+ view!.setRenderedHeight(300);
247
+ runFrames();
248
+ expect(view!.scrollCount).to.equal(1);
249
+
250
+ runFrames();
251
+ expect(view!.scrollCount).to.equal(1);
252
+ });
253
+
254
+ it('waits for the latest list height when the first reported height shrinks', () => {
255
+ render({ scrollToRow: 2, scrollToRowRequestId: 1 });
256
+ view!.measure(300, 100);
257
+ view!.measure(200, 200);
258
+ runFrames();
259
+ expect(view!.scrollCount).to.equal(1);
260
+ });
261
+
262
+ it('serves a held scroll request when the list is never rendered at its height', () => {
218
263
  render({ scrollToRow: 2, scrollToRowRequestId: 1 });
264
+ view!.measure(300, 100);
265
+ for (let frame = 0; frame < 30; frame++) {
266
+ runFrames();
267
+ expect(view!.scrollCount).to.equal(0);
268
+ }
269
+ runFrames();
270
+ expect(view!.scrollCount).to.equal(1);
271
+ });
272
+
273
+ it('does not scroll again when re-rendered without a new scroll request', () => {
274
+ mount({ scrollToRow: 2, scrollToRowRequestId: 1 });
219
275
  const scrollsAfterMount = view!.scrollCount;
220
276
 
221
277
  render({ scrollToRow: 2, scrollToRowRequestId: 1 });
@@ -225,7 +281,7 @@ describe('TreeWidget', () => {
225
281
  });
226
282
 
227
283
  it('scrolls again when a new scroll request arrives for the same row', () => {
228
- render({ scrollToRow: 2, scrollToRowRequestId: 1 });
284
+ mount({ scrollToRow: 2, scrollToRowRequestId: 1 });
229
285
  const scrollsAfterMount = view!.scrollCount;
230
286
 
231
287
  render({ scrollToRow: 2, scrollToRowRequestId: 2 });
@@ -234,7 +290,7 @@ describe('TreeWidget', () => {
234
290
  });
235
291
 
236
292
  it('scrolls when the row to scroll to changes', () => {
237
- render({ scrollToRow: 1 });
293
+ mount({ scrollToRow: 1 });
238
294
  const scrollsAfterMount = view!.scrollCount;
239
295
 
240
296
  render({ scrollToRow: 2 });
@@ -1655,13 +1655,73 @@ export namespace TreeWidget {
1655
1655
  }
1656
1656
  };
1657
1657
 
1658
+ /**
1659
+ * `true` once Virtuoso has measured the list. Before that, `scrollIntoView` sees every row as visible and does nothing.
1660
+ */
1661
+ protected measured = false;
1662
+ /**
1663
+ * `true` while a scroll request made before the list was measured waits to be served.
1664
+ */
1665
+ protected scrollHeld = false;
1666
+ /**
1667
+ * The latest list height reported by Virtuoso. It can still change after the first report, as more rows are measured.
1668
+ */
1669
+ protected listHeight = 0;
1670
+ /**
1671
+ * How many frames a held scroll request waits for the list to be rendered at its height, about half a second at 60 Hz.
1672
+ */
1673
+ protected readonly maxRenderWaitFrames = 30;
1674
+ protected scroller: HTMLElement | undefined;
1675
+
1676
+ /**
1677
+ * Scroll now, or hold the request until the list is measured, e.g. for the focus restored when the view mounts.
1678
+ */
1679
+ protected requestScroll(): void {
1680
+ if (this.measured) {
1681
+ this.scrollIntoViewIfNeeded();
1682
+ } else {
1683
+ this.scrollHeld = this.props.scrollToRow !== undefined;
1684
+ }
1685
+ }
1686
+
1687
+ protected readonly onTotalListHeightChanged = (height: number): void => {
1688
+ this.listHeight = height;
1689
+ if (!this.measured && height > 0) {
1690
+ this.measured = true;
1691
+ if (this.scrollHeld) {
1692
+ this.scrollHeld = false;
1693
+ this.scrollWhenRendered();
1694
+ }
1695
+ }
1696
+ this.props.totalListHeightChanged?.(height);
1697
+ };
1698
+
1699
+ protected readonly setScroller = (scroller: HTMLElement | Window | null): void => {
1700
+ this.scroller = scroller instanceof HTMLElement ? scroller : undefined;
1701
+ this.props.scrollerRef?.(scroller);
1702
+ };
1703
+
1704
+ /**
1705
+ * Virtuoso reports the list height before React renders the list at it, and a scroll before that is clamped
1706
+ * to the old height. Wait for the rendered height, at most `maxRenderWaitFrames` frames.
1707
+ */
1708
+ protected scrollWhenRendered(framesLeft = this.maxRenderWaitFrames): void {
1709
+ window.requestAnimationFrame(() => {
1710
+ if (this.scroller && this.scroller.scrollHeight < this.listHeight && framesLeft > 0) {
1711
+ this.scrollWhenRendered(framesLeft - 1);
1712
+ } else {
1713
+ this.scrollIntoViewIfNeeded();
1714
+ }
1715
+ });
1716
+ }
1717
+
1658
1718
  override componentDidMount(): void {
1659
- this.scrollIntoViewIfNeeded();
1719
+ this.requestScroll();
1660
1720
  }
1661
1721
 
1662
1722
  override componentDidUpdate(prevProps: ViewProps): void {
1663
1723
  if (this.props.scrollToRow !== prevProps.scrollToRow || this.props.scrollToRowRequestId !== prevProps.scrollToRowRequestId) {
1664
- this.scrollIntoViewIfNeeded();
1724
+ this.requestScroll();
1665
1725
  }
1666
1726
  }
1667
1727
 
@@ -1681,6 +1741,8 @@ export namespace TreeWidget {
1681
1741
  // Higher value provides smoother scrolling experience especially during inference, but uses more memory
1682
1742
  overscan={800}
1683
1743
  {...other}
1744
+ totalListHeightChanged={this.onTotalListHeightChanged}
1745
+ scrollerRef={this.setScroller}
1684
1746
  />;
1685
1747
  }
1686
1748
  }
@@ -0,0 +1,77 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
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 { expect } from 'chai';
21
+ import * as React from 'react';
22
+ import * as sinon from 'sinon';
23
+ import * as browser from '../browser';
24
+ import { SelectComponent, SelectOption } from './select-component';
25
+
26
+ disableJSDOM();
27
+
28
+ class TestSelectComponent extends SelectComponent {
29
+ dropdown(): React.ReactElement<{ style: React.CSSProperties }> {
30
+ this.state = { ...this.state, dimensions: new DOMRect(20, 20, 90, 23) };
31
+ return this.renderDropdown() as React.ReactElement<{ style: React.CSSProperties }>;
32
+ }
33
+ }
34
+
35
+ describe('SelectComponent dropdown width', () => {
36
+ let component: TestSelectComponent;
37
+ let textWidth: sinon.SinonStub;
38
+
39
+ before(() => disableJSDOM = enableJSDOM());
40
+ after(() => disableJSDOM());
41
+
42
+ beforeEach(() => {
43
+ textWidth = sinon.stub(browser, 'measureTextWidth').returns(84);
44
+ sinon.stub(document.documentElement, 'getBoundingClientRect').returns(new DOMRect(0, 0, 800, 600));
45
+ });
46
+
47
+ afterEach(() => {
48
+ component.componentWillUnmount();
49
+ sinon.restore();
50
+ });
51
+
52
+ function dropdown(options: SelectOption[], alignment?: 'left' | 'right'): React.ReactElement<{ style: React.CSSProperties }> {
53
+ component = new TestSelectComponent({ options, alignment });
54
+ return component.dropdown();
55
+ }
56
+
57
+ it('keeps label-based sizing when no option has a description', () => {
58
+ expect(dropdown([{ label: 'Off' }, { label: 'None', description: '' }]).props.style.width).to.equal(100);
59
+ });
60
+
61
+ it('reserves readable space even when only an unselected option has a description', () => {
62
+ expect(dropdown([{ label: 'Auto' }, { label: 'Off', description: 'Uses provider defaults.' }]).props.style.width).to.equal(300);
63
+ });
64
+
65
+ it('keeps enough space for labels wider than the description minimum', () => {
66
+ textWidth.returns(384);
67
+ expect(dropdown([{ label: 'A long label', description: 'Description' }]).props.style.width).to.equal(400);
68
+ });
69
+
70
+ it('caps the description width at the available space for either alignment', () => {
71
+ (document.documentElement.getBoundingClientRect as sinon.SinonStub).returns(new DOMRect(0, 0, 200, 600));
72
+ const options = [{ label: 'Off', description: 'Uses provider defaults.' }];
73
+ expect(dropdown(options).props.style.width).to.equal(180);
74
+ component.componentWillUnmount();
75
+ expect(dropdown(options, 'right').props.style.width).to.equal(110);
76
+ });
77
+ });
@@ -163,7 +163,8 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
163
163
 
164
164
  protected getOptimalWidth(): number {
165
165
  const textWidth = measureTextWidth(this.props.options.map(e => e.label || e.value || '' + (e.detail || '')));
166
- return Math.ceil(textWidth + 16);
166
+ const minWidth = this.props.options.some(option => option.description) ? 300 : 0;
167
+ return Math.max(Math.ceil(textWidth + 16), minWidth);
167
168
  }
168
169
 
169
170
  protected getOptimalHeight(maxWidth?: number): number {
@@ -320,15 +321,10 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
320
321
  if (!this.fieldRef.current) {
321
322
  return;
322
323
  }
323
- if (ev.key === 'ArrowUp') {
324
- const selected = this.nextNotSeparator('backwards', this.state.hover);
325
- this.setState({
326
- selected,
327
- hover: selected
328
- });
329
- } else if (ev.key === 'ArrowDown') {
324
+ if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
325
+ const direction = ev.key === 'ArrowUp' ? 'backwards' : 'forwards';
330
326
  if (this.state.dimensions) {
331
- const selected = this.nextNotSeparator('forwards', this.state.hover);
327
+ const selected = this.nextNotSeparator(direction, this.state.hover);
332
328
  this.setState({
333
329
  selected,
334
330
  hover: selected
@@ -337,7 +333,7 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
337
333
  this.toggleVisibility();
338
334
  // Start on the first selectable option (startFrom -1 evaluates index 0 first), so a
339
335
  // leading separator or disabled option is skipped instead of becoming the active row.
340
- const selected = this.nextNotSeparator('forwards', -1);
336
+ const selected = this.nextNotSeparator(direction, -1);
341
337
  this.setState({
342
338
  selected,
343
339
  hover: selected,