@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.
- package/README.md +4 -4
- package/lib/browser/catalog.json +207 -22
- package/lib/browser/color-application-contribution.d.ts +6 -0
- package/lib/browser/color-application-contribution.d.ts.map +1 -1
- package/lib/browser/color-application-contribution.js +15 -4
- package/lib/browser/color-application-contribution.js.map +1 -1
- package/lib/browser/color-application-contribution.spec.d.ts +2 -0
- package/lib/browser/color-application-contribution.spec.d.ts.map +1 -0
- package/lib/browser/color-application-contribution.spec.js +96 -0
- package/lib/browser/color-application-contribution.spec.js.map +1 -0
- package/lib/browser/color-registry.d.ts +6 -0
- package/lib/browser/color-registry.d.ts.map +1 -1
- package/lib/browser/color-registry.js +12 -0
- package/lib/browser/color-registry.js.map +1 -1
- package/lib/browser/tree/tree-selection-state.d.ts +23 -1
- package/lib/browser/tree/tree-selection-state.d.ts.map +1 -1
- package/lib/browser/tree/tree-selection-state.js +23 -1
- package/lib/browser/tree/tree-selection-state.js.map +1 -1
- package/lib/browser/tree/tree-widget.d.ts +28 -0
- package/lib/browser/tree/tree-widget.d.ts.map +1 -1
- package/lib/browser/tree/tree-widget.js +59 -3
- package/lib/browser/tree/tree-widget.js.map +1 -1
- package/lib/browser/tree/tree-widget.spec.js +53 -3
- package/lib/browser/tree/tree-widget.spec.js.map +1 -1
- package/lib/browser/widgets/select-component.d.ts.map +1 -1
- package/lib/browser/widgets/select-component.js +6 -11
- package/lib/browser/widgets/select-component.js.map +1 -1
- package/lib/browser/widgets/select-component.spec.d.ts +2 -0
- package/lib/browser/widgets/select-component.spec.d.ts.map +1 -0
- package/lib/browser/widgets/select-component.spec.js +66 -0
- package/lib/browser/widgets/select-component.spec.js.map +1 -0
- package/lib/common/core-preferences.js +5 -5
- package/lib/common/core-preferences.js.map +1 -1
- package/lib/node/messaging/binary-message-pipe.d.ts.map +1 -1
- package/lib/node/messaging/binary-message-pipe.js +5 -3
- package/lib/node/messaging/binary-message-pipe.js.map +1 -1
- package/lib/node/messaging/binary-message-pipe.spec.d.ts +2 -0
- package/lib/node/messaging/binary-message-pipe.spec.d.ts.map +1 -0
- package/lib/node/messaging/binary-message-pipe.spec.js +71 -0
- package/lib/node/messaging/binary-message-pipe.spec.js.map +1 -0
- package/package.json +16 -16
- package/src/browser/color-application-contribution.spec.ts +108 -0
- package/src/browser/color-application-contribution.ts +16 -4
- package/src/browser/color-registry.ts +13 -0
- package/src/browser/style/hover-service.css +0 -2
- package/src/browser/tree/tree-selection-state.ts +40 -1
- package/src/browser/tree/tree-widget.spec.tsx +59 -3
- package/src/browser/tree/tree-widget.tsx +64 -2
- package/src/browser/widgets/select-component.spec.ts +77 -0
- package/src/browser/widgets/select-component.tsx +6 -10
- package/src/common/core-preferences.ts +5 -5
- package/src/node/messaging/binary-message-pipe.spec.ts +81 -0
- 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.
|
|
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.
|
|
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.
|
|
21
|
-
"@theia/request": "1.77.0-next.
|
|
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.
|
|
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.
|
|
38
|
+
"dompurify": "^3.4.16",
|
|
39
39
|
"drivelist": "^12.0.2",
|
|
40
|
-
"express": "^4.22.
|
|
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.
|
|
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.
|
|
66
|
-
"socket.io-client": "^4.8.
|
|
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.
|
|
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.
|
|
227
|
-
"@types/react-dom": "^19.
|
|
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.
|
|
231
|
-
"react-dom": "^19.
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
336
|
+
const selected = this.nextNotSeparator(direction, -1);
|
|
341
337
|
this.setState({
|
|
342
338
|
selected,
|
|
343
339
|
hover: selected,
|