@theia/core 1.74.0-next.5 → 1.74.0-next.53
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 +300 -36
- package/lib/browser/common-frontend-contribution.d.ts.map +1 -1
- package/lib/browser/common-frontend-contribution.js +1 -2
- package/lib/browser/common-frontend-contribution.js.map +1 -1
- package/lib/browser/keybinding.d.ts.map +1 -1
- package/lib/browser/keybinding.js +12 -7
- package/lib/browser/keybinding.js.map +1 -1
- package/lib/browser/preferences/preference-contribution.d.ts.map +1 -1
- package/lib/browser/preferences/preference-contribution.js +9 -1
- package/lib/browser/preferences/preference-contribution.js.map +1 -1
- package/lib/browser/preferences/preference-service.spec.js +63 -0
- package/lib/browser/preferences/preference-service.spec.js.map +1 -1
- package/lib/browser/shell/application-shell.d.ts.map +1 -1
- package/lib/browser/shell/application-shell.js +19 -26
- package/lib/browser/shell/application-shell.js.map +1 -1
- package/lib/browser/tree/tree-widget.d.ts +7 -33
- package/lib/browser/tree/tree-widget.d.ts.map +1 -1
- package/lib/browser/tree/tree-widget.js +7 -54
- package/lib/browser/tree/tree-widget.js.map +1 -1
- package/lib/browser/widgets/select-component.d.ts +2 -2
- package/lib/browser/widgets/select-component.d.ts.map +1 -1
- package/lib/browser/widgets/select-component.js +42 -12
- package/lib/browser/widgets/select-component.js.map +1 -1
- package/lib/common/preferences/index.d.ts +1 -0
- package/lib/common/preferences/index.d.ts.map +1 -1
- package/lib/common/preferences/index.js +1 -0
- package/lib/common/preferences/index.js.map +1 -1
- package/lib/common/preferences/preference-scope.d.ts +6 -1
- package/lib/common/preferences/preference-scope.d.ts.map +1 -1
- package/lib/common/preferences/preference-scope.js +5 -0
- package/lib/common/preferences/preference-scope.js.map +1 -1
- package/lib/common/preferences/preference-scope.spec.js +5 -3
- package/lib/common/preferences/preference-scope.spec.js.map +1 -1
- package/lib/common/preferences/preference-service.d.ts +19 -0
- package/lib/common/preferences/preference-service.d.ts.map +1 -1
- package/lib/common/preferences/preference-service.js +34 -5
- package/lib/common/preferences/preference-service.js.map +1 -1
- package/lib/common/preferences/session-preference-provider.d.ts +17 -0
- package/lib/common/preferences/session-preference-provider.d.ts.map +1 -0
- package/lib/common/preferences/session-preference-provider.js +77 -0
- package/lib/common/preferences/session-preference-provider.js.map +1 -0
- package/lib/common/preferences/session-preference-provider.spec.d.ts +2 -0
- package/lib/common/preferences/session-preference-provider.spec.d.ts.map +1 -0
- package/lib/common/preferences/session-preference-provider.spec.js +71 -0
- package/lib/common/preferences/session-preference-provider.spec.js.map +1 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.d.ts.map +1 -1
- package/lib/electron-node/hosting/electron-backend-hosting-module.js +7 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.js.map +1 -1
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.d.ts +2 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.d.ts.map +1 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.js +39 -0
- package/lib/electron-node/hosting/electron-backend-hosting-module.spec.js.map +1 -0
- package/lib/node/hosting/backend-hosting-module.d.ts.map +1 -1
- package/lib/node/hosting/backend-hosting-module.js +4 -1
- package/lib/node/hosting/backend-hosting-module.js.map +1 -1
- package/lib/node/hosting/browser-connection-token.d.ts +29 -1
- package/lib/node/hosting/browser-connection-token.d.ts.map +1 -1
- package/lib/node/hosting/browser-connection-token.js +25 -1
- package/lib/node/hosting/browser-connection-token.js.map +1 -1
- package/lib/node/hosting/browser-connection-token.spec.js +70 -0
- package/lib/node/hosting/browser-connection-token.spec.js.map +1 -1
- package/lib/node/index.d.ts +1 -0
- package/lib/node/index.d.ts.map +1 -1
- package/lib/node/index.js +3 -1
- package/lib/node/index.js.map +1 -1
- package/package.json +9 -9
- package/src/browser/common-frontend-contribution.ts +1 -2
- package/src/browser/keybinding.ts +12 -7
- package/src/browser/preferences/preference-contribution.ts +9 -1
- package/src/browser/preferences/preference-service.spec.ts +72 -0
- package/src/browser/shell/application-shell.ts +19 -25
- package/src/browser/style/select-component.css +16 -0
- package/src/browser/style/sidepanel.css +5 -0
- package/src/browser/style/status-bar.css +1 -1
- package/src/browser/tree/tree-widget.tsx +12 -78
- package/src/browser/widgets/select-component.tsx +41 -12
- package/src/common/preferences/index.ts +1 -0
- package/src/common/preferences/preference-scope.spec.ts +5 -3
- package/src/common/preferences/preference-scope.ts +6 -1
- package/src/common/preferences/preference-service.ts +39 -5
- package/src/common/preferences/session-preference-provider.spec.ts +78 -0
- package/src/common/preferences/session-preference-provider.ts +69 -0
- package/src/electron-node/hosting/electron-backend-hosting-module.spec.ts +42 -0
- package/src/electron-node/hosting/electron-backend-hosting-module.ts +8 -0
- package/src/node/hosting/backend-hosting-module.ts +5 -2
- package/src/node/hosting/browser-connection-token.spec.ts +92 -0
- package/src/node/hosting/browser-connection-token.ts +45 -1
- package/src/node/index.ts +1 -0
|
@@ -64,28 +64,10 @@ export const TREE_NODE_CAPTION_CLASS = 'theia-TreeNodeCaption';
|
|
|
64
64
|
export const TREE_NODE_INDENT_GUIDE_CLASS = 'theia-tree-node-indent';
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Threshold in pixels to consider the view as being scrolled to the bottom
|
|
67
|
+
* Threshold in pixels to consider the view as being scrolled to the bottom.
|
|
68
68
|
*/
|
|
69
69
|
export const SCROLL_BOTTOM_THRESHOLD = 30;
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
* Tree scroll event data.
|
|
73
|
-
*/
|
|
74
|
-
export interface TreeScrollEvent {
|
|
75
|
-
readonly scrollTop: number;
|
|
76
|
-
readonly scrollLeft: number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Tree scroll state data.
|
|
81
|
-
*/
|
|
82
|
-
export interface TreeScrollState {
|
|
83
|
-
readonly scrollTop: number;
|
|
84
|
-
readonly isAtBottom: boolean;
|
|
85
|
-
readonly scrollHeight?: number;
|
|
86
|
-
readonly clientHeight?: number;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
71
|
export const TreeProps = Symbol('TreeProps');
|
|
90
72
|
|
|
91
73
|
/**
|
|
@@ -189,8 +171,8 @@ export class TreeWidget extends ReactWidget implements StatefulWidget {
|
|
|
189
171
|
protected searchBox: SearchBox;
|
|
190
172
|
protected searchHighlights: Map<string, TreeDecoration.CaptionHighlight>;
|
|
191
173
|
|
|
192
|
-
protected readonly
|
|
193
|
-
readonly
|
|
174
|
+
protected readonly onAtBottomStateChangeEmitter = new Emitter<boolean>();
|
|
175
|
+
readonly onAtBottomStateChange: TheiaEvent<boolean> = this.onAtBottomStateChangeEmitter.event;
|
|
194
176
|
|
|
195
177
|
@inject(TreeDecoratorService)
|
|
196
178
|
protected readonly decoratorService: TreeDecoratorService;
|
|
@@ -285,7 +267,7 @@ export class TreeWidget extends ReactWidget implements StatefulWidget {
|
|
|
285
267
|
this.node.addEventListener('mouseup', this.handleMiddleClickEvent.bind(this));
|
|
286
268
|
this.node.addEventListener('auxclick', this.handleMiddleClickEvent.bind(this));
|
|
287
269
|
this.toDispose.pushAll([
|
|
288
|
-
this.
|
|
270
|
+
this.onAtBottomStateChangeEmitter,
|
|
289
271
|
this.model,
|
|
290
272
|
this.model.onChanged(() => this.updateRows()),
|
|
291
273
|
this.model.onSelectionChanged(() => this.scheduleUpdateScrollToRow({ resize: false })),
|
|
@@ -551,7 +533,7 @@ export class TreeWidget extends ReactWidget implements StatefulWidget {
|
|
|
551
533
|
rows={rows}
|
|
552
534
|
renderNodeRow={this.renderNodeRow}
|
|
553
535
|
scrollToRow={this.scrollToRow}
|
|
554
|
-
|
|
536
|
+
onAtBottomStateChangeEmitter={this.onAtBottomStateChangeEmitter}
|
|
555
537
|
{...this.props.viewProps}
|
|
556
538
|
/>;
|
|
557
539
|
}
|
|
@@ -1201,39 +1183,6 @@ export class TreeWidget extends ReactWidget implements StatefulWidget {
|
|
|
1201
1183
|
return this.node;
|
|
1202
1184
|
}
|
|
1203
1185
|
|
|
1204
|
-
/**
|
|
1205
|
-
* Get the current scroll state from the virtualized view.
|
|
1206
|
-
* This should be used instead of accessing the DOM scroll properties directly
|
|
1207
|
-
* when the tree is virtualized.
|
|
1208
|
-
*/
|
|
1209
|
-
protected getVirtualizedScrollState(): TreeScrollState | undefined {
|
|
1210
|
-
return this.view?.getScrollState();
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
/**
|
|
1214
|
-
* Check if the tree is scrolled to the bottom.
|
|
1215
|
-
* Works with both virtualized and non-virtualized trees.
|
|
1216
|
-
*/
|
|
1217
|
-
isScrolledToBottom(): boolean {
|
|
1218
|
-
if (this.props.virtualized !== false && this.view) {
|
|
1219
|
-
// Use virtualized scroll state
|
|
1220
|
-
const scrollState = this.getVirtualizedScrollState();
|
|
1221
|
-
return scrollState?.isAtBottom ?? true;
|
|
1222
|
-
} else {
|
|
1223
|
-
// Fallback to DOM-based calculation for non-virtualized trees
|
|
1224
|
-
const scrollContainer = this.node;
|
|
1225
|
-
const scrollHeight = scrollContainer.scrollHeight;
|
|
1226
|
-
const scrollTop = scrollContainer.scrollTop;
|
|
1227
|
-
const clientHeight = scrollContainer.clientHeight;
|
|
1228
|
-
|
|
1229
|
-
if (scrollHeight <= clientHeight) {
|
|
1230
|
-
return true;
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
return scrollHeight - scrollTop - clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
1186
|
protected override onAfterAttach(msg: Message): void {
|
|
1238
1187
|
const up = [
|
|
1239
1188
|
Key.ARROW_UP,
|
|
@@ -1658,11 +1607,14 @@ export namespace TreeWidget {
|
|
|
1658
1607
|
/**
|
|
1659
1608
|
* Optional scroll event emitter.
|
|
1660
1609
|
*/
|
|
1661
|
-
|
|
1610
|
+
/**
|
|
1611
|
+
* Optional emitter fired when the at-bottom state changes.
|
|
1612
|
+
*/
|
|
1613
|
+
onAtBottomStateChangeEmitter?: Emitter<boolean>
|
|
1662
1614
|
}
|
|
1663
1615
|
export class View extends React.Component<ViewProps> {
|
|
1664
1616
|
list: VirtuosoHandle | undefined;
|
|
1665
|
-
|
|
1617
|
+
|
|
1666
1618
|
/**
|
|
1667
1619
|
* Ensure the selected row is scrolled into view when virtualization finishes updating.
|
|
1668
1620
|
*/
|
|
@@ -1687,28 +1639,14 @@ export namespace TreeWidget {
|
|
|
1687
1639
|
}
|
|
1688
1640
|
|
|
1689
1641
|
override render(): React.ReactNode {
|
|
1690
|
-
const { rows, width, height, scrollToRow, renderNodeRow,
|
|
1642
|
+
const { rows, width, height, scrollToRow, renderNodeRow, onAtBottomStateChangeEmitter, ...other } = this.props;
|
|
1691
1643
|
return <Virtuoso
|
|
1692
1644
|
ref={(list: VirtuosoHandle | null) => {
|
|
1693
1645
|
this.list = (list || undefined);
|
|
1694
1646
|
this.scrollIntoViewIfNeeded();
|
|
1695
1647
|
}}
|
|
1696
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1697
|
-
onScroll={(e: any) => {
|
|
1698
|
-
const scrollTop = e.target.scrollTop;
|
|
1699
|
-
const scrollHeight = e.target.scrollHeight;
|
|
1700
|
-
const clientHeight = e.target.clientHeight;
|
|
1701
|
-
const isAtBottom = scrollHeight - scrollTop - clientHeight <= SCROLL_BOTTOM_THRESHOLD;
|
|
1702
|
-
|
|
1703
|
-
// Store scroll state before firing the event to prevent jitter during inference and scrolling
|
|
1704
|
-
this.lastScrollState = { scrollTop, isAtBottom, scrollHeight, clientHeight };
|
|
1705
|
-
onScrollEmitter?.fire({ scrollTop, scrollLeft: e.target.scrollLeft || 0 });
|
|
1706
|
-
}}
|
|
1707
1648
|
atBottomStateChange={(atBottom: boolean) => {
|
|
1708
|
-
|
|
1709
|
-
...this.lastScrollState,
|
|
1710
|
-
isAtBottom: atBottom
|
|
1711
|
-
};
|
|
1649
|
+
onAtBottomStateChangeEmitter?.fire(atBottom);
|
|
1712
1650
|
}}
|
|
1713
1651
|
atBottomThreshold={SCROLL_BOTTOM_THRESHOLD}
|
|
1714
1652
|
totalCount={rows.length}
|
|
@@ -1721,9 +1659,5 @@ export namespace TreeWidget {
|
|
|
1721
1659
|
{...other}
|
|
1722
1660
|
/>;
|
|
1723
1661
|
}
|
|
1724
|
-
|
|
1725
|
-
getScrollState(): TreeScrollState {
|
|
1726
|
-
return { ...this.lastScrollState };
|
|
1727
|
-
}
|
|
1728
1662
|
}
|
|
1729
1663
|
}
|
|
@@ -91,7 +91,20 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
91
91
|
return selected;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
override componentDidUpdate(prevProps: SelectComponentProps): void {
|
|
94
|
+
override componentDidUpdate(prevProps: SelectComponentProps, prevState: SelectComponentState): void {
|
|
95
|
+
if (prevState.selected !== this.state.selected && this.state.dimensions) {
|
|
96
|
+
const dropdownContainer = this.dropdownRef?.current;
|
|
97
|
+
const selectedOption = dropdownContainer?.querySelector(`[data-option-index="${this.state.selected}"]`) as HTMLElement | undefined;
|
|
98
|
+
if (dropdownContainer && selectedOption) {
|
|
99
|
+
const optionBottom = selectedOption.offsetTop + selectedOption.offsetHeight;
|
|
100
|
+
const visibleBottom = dropdownContainer.scrollTop + dropdownContainer.clientHeight;
|
|
101
|
+
if (optionBottom > visibleBottom) {
|
|
102
|
+
dropdownContainer.scrollTop = optionBottom - dropdownContainer.clientHeight;
|
|
103
|
+
} else if (selectedOption.offsetTop < dropdownContainer.scrollTop) {
|
|
104
|
+
dropdownContainer.scrollTop = selectedOption.offsetTop;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
95
108
|
if (prevProps.defaultValue !== this.props.defaultValue || prevProps.options !== this.props.options) {
|
|
96
109
|
const selected = this.getInitialSelectedIndex(this.props);
|
|
97
110
|
this.setState({
|
|
@@ -230,13 +243,18 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
230
243
|
selected = this.nextNotSeparator('forwards');
|
|
231
244
|
}
|
|
232
245
|
const selectedItemLabel = options[selected]?.label ?? options[selected]?.value;
|
|
246
|
+
// Expose when the currently shown option is disabled so consumers can style the field to match
|
|
247
|
+
// it (e.g. the option previewed while navigating), instead of a fixed committed-value class.
|
|
248
|
+
const selectionDisabled = !!options[selected]?.disabled;
|
|
249
|
+
const fieldClassName = `theia-select-component${this.props.className ? ` ${this.props.className}` : ''}`
|
|
250
|
+
+ `${selectionDisabled ? ' theia-select-component-selection-disabled' : ''}`;
|
|
233
251
|
return <>
|
|
234
252
|
<div
|
|
235
253
|
id={this.props.id}
|
|
236
254
|
key="select-component"
|
|
237
255
|
ref={this.fieldRef}
|
|
238
256
|
tabIndex={0}
|
|
239
|
-
className={
|
|
257
|
+
className={fieldClassName}
|
|
240
258
|
onClick={e => this.handleClickEvent(e)}
|
|
241
259
|
onBlur={
|
|
242
260
|
() => {
|
|
@@ -254,11 +272,11 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
254
272
|
</>;
|
|
255
273
|
}
|
|
256
274
|
|
|
257
|
-
protected nextNotSeparator(direction: 'forwards' | 'backwards'): number {
|
|
275
|
+
protected nextNotSeparator(direction: 'forwards' | 'backwards', startFrom?: number): number {
|
|
258
276
|
const { options } = this.props;
|
|
259
277
|
const step = direction === 'forwards' ? 1 : -1;
|
|
260
278
|
const length = this.props.options.length;
|
|
261
|
-
let selected = this.state.selected;
|
|
279
|
+
let selected = startFrom ?? this.state.selected;
|
|
262
280
|
let count = 0;
|
|
263
281
|
do {
|
|
264
282
|
selected = (selected + step) % length;
|
|
@@ -267,7 +285,8 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
267
285
|
}
|
|
268
286
|
count++;
|
|
269
287
|
}
|
|
270
|
-
|
|
288
|
+
// Skip separators and disabled options so keyboard navigation never lands on a non-selectable entry.
|
|
289
|
+
while ((options[selected]?.separator || options[selected]?.disabled) && count < length);
|
|
271
290
|
return selected;
|
|
272
291
|
}
|
|
273
292
|
|
|
@@ -276,23 +295,26 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
276
295
|
return;
|
|
277
296
|
}
|
|
278
297
|
if (ev.key === 'ArrowUp') {
|
|
279
|
-
const selected = this.nextNotSeparator('backwards');
|
|
298
|
+
const selected = this.nextNotSeparator('backwards', this.state.hover);
|
|
280
299
|
this.setState({
|
|
281
300
|
selected,
|
|
282
301
|
hover: selected
|
|
283
302
|
});
|
|
284
303
|
} else if (ev.key === 'ArrowDown') {
|
|
285
304
|
if (this.state.dimensions) {
|
|
286
|
-
const selected = this.nextNotSeparator('forwards');
|
|
305
|
+
const selected = this.nextNotSeparator('forwards', this.state.hover);
|
|
287
306
|
this.setState({
|
|
288
307
|
selected,
|
|
289
308
|
hover: selected
|
|
290
309
|
});
|
|
291
310
|
} else {
|
|
292
311
|
this.toggleVisibility();
|
|
312
|
+
// Start on the first selectable option (startFrom -1 evaluates index 0 first), so a
|
|
313
|
+
// leading separator or disabled option is skipped instead of becoming the active row.
|
|
314
|
+
const selected = this.nextNotSeparator('forwards', -1);
|
|
293
315
|
this.setState({
|
|
294
|
-
selected
|
|
295
|
-
hover:
|
|
316
|
+
selected,
|
|
317
|
+
hover: selected,
|
|
296
318
|
});
|
|
297
319
|
}
|
|
298
320
|
} else if (ev.key === 'Enter') {
|
|
@@ -300,7 +322,10 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
300
322
|
this.toggleVisibility();
|
|
301
323
|
} else {
|
|
302
324
|
const selected = this.state.selected;
|
|
303
|
-
|
|
325
|
+
const option = this.props.options[selected];
|
|
326
|
+
if (!option?.disabled) {
|
|
327
|
+
this.selectOption(selected, option);
|
|
328
|
+
}
|
|
304
329
|
}
|
|
305
330
|
} else if (ev.key === 'Escape' || ev.key === 'Tab') {
|
|
306
331
|
this.hide(undefined, true);
|
|
@@ -401,17 +426,21 @@ export class SelectComponent extends React.Component<SelectComponentProps, Selec
|
|
|
401
426
|
return <div key={index} className="theia-select-component-separator" />;
|
|
402
427
|
}
|
|
403
428
|
const selected = this.state.hover;
|
|
429
|
+
const disabled = !!option.disabled;
|
|
404
430
|
return (
|
|
405
431
|
<div
|
|
406
432
|
key={index}
|
|
407
|
-
|
|
433
|
+
data-option-index={index}
|
|
434
|
+
className={`theia-select-component-option${index === selected ? ' selected' : ''}${disabled ? ' disabled' : ''}`}
|
|
408
435
|
onMouseOver={() => {
|
|
409
436
|
this.setState({
|
|
410
437
|
hover: index
|
|
411
438
|
});
|
|
412
439
|
}}
|
|
413
440
|
onMouseDown={() => {
|
|
414
|
-
|
|
441
|
+
if (!disabled) {
|
|
442
|
+
this.selectOption(index, option);
|
|
443
|
+
}
|
|
415
444
|
}}
|
|
416
445
|
>
|
|
417
446
|
<div key="value" className="theia-select-component-option-value">{option.label ?? option.value}</div>
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
export * from './defaults-preference-provider';
|
|
18
18
|
export * from './preference-language-override-service';
|
|
19
|
+
export * from './session-preference-provider';
|
|
19
20
|
export * from './preference-provider-impl';
|
|
20
21
|
export * from './preference-provider';
|
|
21
22
|
export * from './preference-schema-service';
|
|
@@ -20,11 +20,11 @@ import { PreferenceScope } from './preference-scope';
|
|
|
20
20
|
describe('PreferenceScope', () => {
|
|
21
21
|
|
|
22
22
|
it('getScopes() should return numbers from broadest to narrowest', () => {
|
|
23
|
-
expect(PreferenceScope.getScopes()).deep.equal([0, 1, 2, 3]);
|
|
23
|
+
expect(PreferenceScope.getScopes()).deep.equal([0, 1, 2, 3, 4]);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
it('getReversedScopes() should return numbers from narrowest to broadest', () => {
|
|
27
|
-
expect(PreferenceScope.getReversedScopes()).deep.equal([3, 2, 1, 0]);
|
|
27
|
+
expect(PreferenceScope.getReversedScopes()).deep.equal([4, 3, 2, 1, 0]);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it('getScopeNames() should return the names of scopes broader than the current one', () => {
|
|
@@ -36,11 +36,13 @@ describe('PreferenceScope', () => {
|
|
|
36
36
|
expect(PreferenceScope.is(PreferenceScope.User)).to.be.true;
|
|
37
37
|
expect(PreferenceScope.is(PreferenceScope.Workspace)).to.be.true;
|
|
38
38
|
expect(PreferenceScope.is(PreferenceScope.Folder)).to.be.true;
|
|
39
|
+
expect(PreferenceScope.is(PreferenceScope.Session)).to.be.true;
|
|
39
40
|
expect(PreferenceScope.is(0)).to.be.true;
|
|
40
41
|
expect(PreferenceScope.is(1)).to.be.true;
|
|
41
42
|
expect(PreferenceScope.is(2)).to.be.true;
|
|
42
43
|
expect(PreferenceScope.is(3)).to.be.true;
|
|
43
|
-
expect(PreferenceScope.is(4)).to.be.
|
|
44
|
+
expect(PreferenceScope.is(4)).to.be.true;
|
|
45
|
+
expect(PreferenceScope.is(5)).to.be.false;
|
|
44
46
|
expect(PreferenceScope.is(-1)).to.be.false;
|
|
45
47
|
expect(PreferenceScope.is({})).to.be.false;
|
|
46
48
|
expect(PreferenceScope.is('Default')).to.be.false;
|
|
@@ -23,7 +23,12 @@ export enum PreferenceScope {
|
|
|
23
23
|
Default,
|
|
24
24
|
User,
|
|
25
25
|
Workspace,
|
|
26
|
-
Folder
|
|
26
|
+
Folder,
|
|
27
|
+
/**
|
|
28
|
+
* Process-lifetime, in-memory scope used by `--session-preference` CLI overrides.
|
|
29
|
+
* Has the highest precedence and is never persisted to disk.
|
|
30
|
+
*/
|
|
31
|
+
Session
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export namespace PreferenceScope {
|
|
@@ -254,6 +254,10 @@ export interface PreferenceInspection<T = JSONValue> {
|
|
|
254
254
|
* Value in folder scope.
|
|
255
255
|
*/
|
|
256
256
|
workspaceFolderValue: T | undefined,
|
|
257
|
+
/**
|
|
258
|
+
* Value in session scope (in-memory, set via `--session-preference`).
|
|
259
|
+
*/
|
|
260
|
+
sessionValue?: T | undefined,
|
|
257
261
|
/**
|
|
258
262
|
* The value that is active, i.e. the value set in the lowest scope available.
|
|
259
263
|
*/
|
|
@@ -453,11 +457,37 @@ export class PreferenceServiceImpl implements PreferenceService {
|
|
|
453
457
|
}
|
|
454
458
|
const provider = this.getProvider(resolvedScope);
|
|
455
459
|
if (provider && await provider.setPreference(preferenceName, value, resourceUri)) {
|
|
460
|
+
await this.evictSessionOverride(preferenceName, resolvedScope);
|
|
456
461
|
return;
|
|
457
462
|
}
|
|
458
463
|
throw new Error(`Unable to write to ${PreferenceScope[resolvedScope]} Settings.`);
|
|
459
464
|
}
|
|
460
465
|
|
|
466
|
+
/**
|
|
467
|
+
* When a preference is written to a persistent scope, drop any in-memory session
|
|
468
|
+
* override (set via `--session-preference`) for the same key so that the explicit
|
|
469
|
+
* change takes effect for the remainder of the session. Without this, the session
|
|
470
|
+
* scope would keep shadowing the user's change because it has higher precedence.
|
|
471
|
+
*
|
|
472
|
+
* Note: this fires for any write to a persistent scope, including programmatic
|
|
473
|
+
* writes from extensions. Extensions that race with startup CLI overrides will
|
|
474
|
+
* therefore clear them. This is intentional: a programmatic write is "explicit"
|
|
475
|
+
* from the preference system's point of view, but consumers should be aware.
|
|
476
|
+
*
|
|
477
|
+
* Session preferences are global (no per-resource scoping), so `resourceUri` is
|
|
478
|
+
* not threaded through.
|
|
479
|
+
*/
|
|
480
|
+
protected async evictSessionOverride(preferenceName: string, writtenScope: PreferenceScope): Promise<void> {
|
|
481
|
+
if (writtenScope === PreferenceScope.Session || writtenScope === PreferenceScope.Default) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const sessionProvider = this.getProvider(PreferenceScope.Session);
|
|
485
|
+
if (sessionProvider && sessionProvider.get(preferenceName) !== undefined) {
|
|
486
|
+
// Passing `undefined` clears the value in the session provider.
|
|
487
|
+
await sessionProvider.setPreference(preferenceName, undefined as unknown as JSONValue);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
461
491
|
getBoolean(preferenceName: string): boolean | undefined;
|
|
462
492
|
getBoolean(preferenceName: string, defaultValue: boolean): boolean;
|
|
463
493
|
getBoolean(preferenceName: string, defaultValue: boolean, resourceUri: string): boolean;
|
|
@@ -499,10 +529,11 @@ export class PreferenceServiceImpl implements PreferenceService {
|
|
|
499
529
|
const globalValue = this.inspectInScope<T>(preferenceName, PreferenceScope.User, resourceUri, forceLanguageOverride);
|
|
500
530
|
const workspaceValue = this.inspectInScope<T>(preferenceName, PreferenceScope.Workspace, resourceUri, forceLanguageOverride);
|
|
501
531
|
const workspaceFolderValue = this.inspectInScope<T>(preferenceName, PreferenceScope.Folder, resourceUri, forceLanguageOverride);
|
|
532
|
+
const sessionValue = this.inspectInScope<T>(preferenceName, PreferenceScope.Session, resourceUri, forceLanguageOverride);
|
|
502
533
|
|
|
503
|
-
const valueApplied = workspaceFolderValue ?? workspaceValue ?? globalValue ?? defaultValue;
|
|
534
|
+
const valueApplied = sessionValue ?? workspaceFolderValue ?? workspaceValue ?? globalValue ?? defaultValue;
|
|
504
535
|
|
|
505
|
-
return { preferenceName, defaultValue, globalValue, workspaceValue, workspaceFolderValue, value: valueApplied };
|
|
536
|
+
return { preferenceName, defaultValue, globalValue, workspaceValue, workspaceFolderValue, sessionValue, value: valueApplied };
|
|
506
537
|
}
|
|
507
538
|
|
|
508
539
|
inspectInScope<T extends JSONValue>(preferenceName: string, scope: PreferenceScope, resourceUri?: string, forceLanguageOverride?: boolean): T | undefined {
|
|
@@ -526,6 +557,8 @@ export class PreferenceServiceImpl implements PreferenceService {
|
|
|
526
557
|
return inspection.workspaceValue;
|
|
527
558
|
case PreferenceScope.Folder:
|
|
528
559
|
return inspection.workspaceFolderValue;
|
|
560
|
+
case PreferenceScope.Session:
|
|
561
|
+
return inspection.sessionValue;
|
|
529
562
|
}
|
|
530
563
|
unreachable(scope, 'Not all PreferenceScope enum variants handled.');
|
|
531
564
|
}
|
|
@@ -551,9 +584,10 @@ export class PreferenceServiceImpl implements PreferenceService {
|
|
|
551
584
|
}
|
|
552
585
|
|
|
553
586
|
// Scopes in ascending order of scope breadth.
|
|
554
|
-
const allScopes = [...this.schemaService.validScopes].reverse()
|
|
555
|
-
|
|
556
|
-
|
|
587
|
+
const allScopes = [...this.schemaService.validScopes].reverse()
|
|
588
|
+
// Default is read-only, and Session is populated only by `--session-preference`,
|
|
589
|
+
// so neither is a valid target for a regular user edit.
|
|
590
|
+
.filter(scope => scope !== PreferenceScope.Default && scope !== PreferenceScope.Session);
|
|
557
591
|
|
|
558
592
|
const isScopeDefined = (scope: PreferenceScope) => this.getScopedValueFromInspection(inspection, scope) !== undefined;
|
|
559
593
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource 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 { expect } from 'chai';
|
|
18
|
+
import { SessionPreferenceProvider } from './session-preference-provider';
|
|
19
|
+
import { PreferenceScope } from './preference-scope';
|
|
20
|
+
|
|
21
|
+
describe('SessionPreferenceProvider', () => {
|
|
22
|
+
|
|
23
|
+
let provider: SessionPreferenceProvider;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
provider = new SessionPreferenceProvider();
|
|
27
|
+
// PostConstruct emulation
|
|
28
|
+
(provider as unknown as { init(): void }).init();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('claims only the Session scope', () => {
|
|
32
|
+
expect(provider.canHandleScope(PreferenceScope.Session)).to.be.true;
|
|
33
|
+
expect(provider.canHandleScope(PreferenceScope.User)).to.be.false;
|
|
34
|
+
expect(provider.canHandleScope(PreferenceScope.Workspace)).to.be.false;
|
|
35
|
+
expect(provider.canHandleScope(PreferenceScope.Folder)).to.be.false;
|
|
36
|
+
expect(provider.canHandleScope(PreferenceScope.Default)).to.be.false;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('starts empty', () => {
|
|
40
|
+
expect(provider.getPreferences()).to.deep.equal({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('stores set values in memory and exposes them via get/resolve', async () => {
|
|
44
|
+
const ok = await provider.setPreference('editor.fontSize', 18);
|
|
45
|
+
expect(ok).to.be.true;
|
|
46
|
+
expect(provider.get('editor.fontSize')).to.equal(18);
|
|
47
|
+
expect(provider.resolve('editor.fontSize').value).to.equal(18);
|
|
48
|
+
expect(provider.getPreferences()).to.deep.equal({ 'editor.fontSize': 18 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('emits a change event on set', async () => {
|
|
52
|
+
let received: { name: string; newValue: unknown; oldValue: unknown; scope: PreferenceScope } | undefined;
|
|
53
|
+
provider.onDidPreferencesChanged(changes => {
|
|
54
|
+
const key = Object.keys(changes)[0];
|
|
55
|
+
received = {
|
|
56
|
+
name: changes[key].preferenceName,
|
|
57
|
+
newValue: changes[key].newValue,
|
|
58
|
+
oldValue: changes[key].oldValue,
|
|
59
|
+
scope: changes[key].scope
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
await provider.setPreference('foo', 'bar');
|
|
63
|
+
expect(received).to.deep.equal({ name: 'foo', newValue: 'bar', oldValue: undefined, scope: PreferenceScope.Session });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('removes a value when set to undefined', async () => {
|
|
67
|
+
await provider.setPreference('foo', 'bar');
|
|
68
|
+
const removed = await provider.setPreference('foo', undefined as never);
|
|
69
|
+
expect(removed).to.be.true;
|
|
70
|
+
expect(provider.get('foo')).to.equal(undefined);
|
|
71
|
+
expect(provider.getPreferences()).to.deep.equal({});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns false when removing a key that was never set', async () => {
|
|
75
|
+
const removed = await provider.setPreference('missing', undefined as never);
|
|
76
|
+
expect(removed).to.be.false;
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource 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 { JSONObject, JSONValue } from '@lumino/coreutils';
|
|
18
|
+
import { injectable, postConstruct } from 'inversify';
|
|
19
|
+
import { PreferenceProvider, PreferenceProviderDataChange } from './preference-provider';
|
|
20
|
+
import { PreferenceProviderImpl } from './preference-provider-impl';
|
|
21
|
+
import { PreferenceScope } from './preference-scope';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* In-memory preference provider used for process-lifetime overrides such as those
|
|
25
|
+
* supplied via the `--session-preference` CLI option. Values live in memory only
|
|
26
|
+
* and are dropped when the process exits.
|
|
27
|
+
*/
|
|
28
|
+
@injectable()
|
|
29
|
+
export class SessionPreferenceProvider extends PreferenceProviderImpl implements PreferenceProvider {
|
|
30
|
+
|
|
31
|
+
protected readonly preferences = new Map<string, JSONValue>();
|
|
32
|
+
|
|
33
|
+
@postConstruct()
|
|
34
|
+
protected init(): void {
|
|
35
|
+
this._ready.resolve();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override canHandleScope(scope: PreferenceScope): boolean {
|
|
39
|
+
return scope === PreferenceScope.Session;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getPreferences(): JSONObject {
|
|
43
|
+
const result: JSONObject = {};
|
|
44
|
+
for (const [name, value] of this.preferences) {
|
|
45
|
+
result[name] = value;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async setPreference(key: string, value: JSONValue | undefined): Promise<boolean> {
|
|
51
|
+
const oldValue = this.preferences.get(key);
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
if (!this.preferences.has(key)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
this.preferences.delete(key);
|
|
57
|
+
} else {
|
|
58
|
+
this.preferences.set(key, value);
|
|
59
|
+
}
|
|
60
|
+
const change: PreferenceProviderDataChange = {
|
|
61
|
+
preferenceName: key,
|
|
62
|
+
newValue: value,
|
|
63
|
+
oldValue,
|
|
64
|
+
scope: PreferenceScope.Session
|
|
65
|
+
};
|
|
66
|
+
await this.emitPreferencesChangedEvent([change]);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource GmbH 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 { expect } from 'chai';
|
|
18
|
+
import { Container } from 'inversify';
|
|
19
|
+
import electronBackendHostingModule from './electron-backend-hosting-module';
|
|
20
|
+
import { HttpConnectionValidator } from '../../node/hosting/browser-connection-token';
|
|
21
|
+
|
|
22
|
+
describe('electron-backend-hosting-module', () => {
|
|
23
|
+
|
|
24
|
+
it('should bind a no-op HttpConnectionValidator that calls next() unconditionally', () => {
|
|
25
|
+
const container = new Container();
|
|
26
|
+
container.load(electronBackendHostingModule);
|
|
27
|
+
const validator = container.get<HttpConnectionValidator>(HttpConnectionValidator);
|
|
28
|
+
|
|
29
|
+
let nextCalled = false;
|
|
30
|
+
let sentStatus: number | undefined;
|
|
31
|
+
// No cookie provided: the no-op validator must still call next() and never send a status.
|
|
32
|
+
const req = { headers: {} as Record<string, string | undefined> };
|
|
33
|
+
const res = { sendStatus: (status: number) => { sentStatus = status; } };
|
|
34
|
+
const next = (): void => { nextCalled = true; };
|
|
35
|
+
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
validator.validateRequest(req as any, res as any, next as any);
|
|
38
|
+
|
|
39
|
+
expect(nextCalled).to.be.true;
|
|
40
|
+
expect(sentStatus).to.be.undefined;
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -16,9 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
import { ContainerModule } from 'inversify';
|
|
18
18
|
import { WsRequestValidatorContribution } from '../../node/ws-request-validators';
|
|
19
|
+
import { HttpConnectionValidator } from '../../node/hosting/browser-connection-token';
|
|
19
20
|
import { ElectronWsOriginValidator } from './electron-ws-origin-validator';
|
|
20
21
|
|
|
21
22
|
export default new ContainerModule(bind => {
|
|
22
23
|
bind(ElectronWsOriginValidator).toSelf().inSingletonScope();
|
|
23
24
|
bind(WsRequestValidatorContribution).toService(ElectronWsOriginValidator);
|
|
25
|
+
|
|
26
|
+
// Electron uses its own `ElectronSecurityToken`, so the connection-token cookie is not used.
|
|
27
|
+
// Bind a no-op validator so consumers can inject it uniformly.
|
|
28
|
+
const noopValidator: HttpConnectionValidator = {
|
|
29
|
+
validateRequest: (_req, _res, next) => next()
|
|
30
|
+
};
|
|
31
|
+
bind(HttpConnectionValidator).toConstantValue(noopValidator);
|
|
24
32
|
});
|
|
@@ -19,7 +19,7 @@ import { BackendApplicationContribution } from '../backend-application';
|
|
|
19
19
|
import { WsRequestValidatorContribution } from '../ws-request-validators';
|
|
20
20
|
import { BackendApplicationHosts } from './backend-application-hosts';
|
|
21
21
|
import {
|
|
22
|
-
BrowserConnectionToken, BrowserConnectionTokenBackendContribution, createBrowserConnectionToken
|
|
22
|
+
BrowserConnectionToken, BrowserConnectionTokenBackendContribution, HttpConnectionValidator, createBrowserConnectionToken
|
|
23
23
|
} from './browser-connection-token';
|
|
24
24
|
import { WsOriginValidator } from './ws-origin-validator';
|
|
25
25
|
|
|
@@ -28,9 +28,12 @@ export default new ContainerModule(bind => {
|
|
|
28
28
|
bind(WsOriginValidator).toSelf().inSingletonScope();
|
|
29
29
|
bind(WsRequestValidatorContribution).toService(WsOriginValidator);
|
|
30
30
|
|
|
31
|
-
// Cookie-based connection token
|
|
31
|
+
// Cookie-based connection token. It is validated on WebSocket upgrades and bootstrapped
|
|
32
|
+
// (set as a cookie) on every HTTP request; HTTP enforcement is opt-in per route via
|
|
33
|
+
// `HttpConnectionValidator`.
|
|
32
34
|
bind(BrowserConnectionToken).toConstantValue(createBrowserConnectionToken());
|
|
33
35
|
bind(BrowserConnectionTokenBackendContribution).toSelf().inSingletonScope();
|
|
34
36
|
bind(BackendApplicationContribution).toService(BrowserConnectionTokenBackendContribution);
|
|
35
37
|
bind(WsRequestValidatorContribution).toService(BrowserConnectionTokenBackendContribution);
|
|
38
|
+
bind(HttpConnectionValidator).toService(BrowserConnectionTokenBackendContribution);
|
|
36
39
|
});
|