@theia/preferences 1.75.0 → 1.76.0-next.10

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.
@@ -34,6 +34,9 @@ import { Preference } from './util/preference-types';
34
34
  import { Event } from '@theia/core/lib/common';
35
35
  import { COMMONLY_USED_SECTION_PREFIX } from './util/preference-layout';
36
36
 
37
+ /** Node id of the 'Commonly Used' group, see `PreferenceTreeGenerator`. */
38
+ const COMMONLY_USED_NODE_ID = `${COMMONLY_USED_SECTION_PREFIX}@${COMMONLY_USED_SECTION_PREFIX}`;
39
+
37
40
  export interface PreferenceTreeNodeProps extends NodeProps {
38
41
  visibleChildren: number;
39
42
  isExpansible?: boolean;
@@ -46,6 +49,7 @@ export enum PreferenceFilterChangeSource {
46
49
  Schema,
47
50
  Search,
48
51
  Scope,
52
+ Category,
49
53
  }
50
54
  export interface PreferenceFilterChangeEvent {
51
55
  source: PreferenceFilterChangeSource
@@ -71,6 +75,14 @@ export class PreferenceTreeModel extends TreeModelImpl {
71
75
  protected _currentRows: Map<string, PreferenceTreeNodeRow> = new Map();
72
76
  protected _totalVisibleLeaves = 0;
73
77
  private _suppressSelection = false;
78
+ private preservingCategoryFilter = false;
79
+ protected _categoryFilterId: string | undefined;
80
+ protected _initialSelectionApplied = false;
81
+ protected lastSelectionId: string | undefined;
82
+
83
+ get categoryFilterId(): string | undefined {
84
+ return this._categoryFilterId;
85
+ }
74
86
 
75
87
  get currentRows(): Readonly<Map<string, PreferenceTreeNodeRow>> {
76
88
  return this._currentRows;
@@ -104,23 +116,44 @@ export class PreferenceTreeModel extends TreeModelImpl {
104
116
  protected async doInit(): Promise<void> {
105
117
  super.init();
106
118
  this.toDispose.pushAll([
119
+ this.onSelectionChanged(selectionEvent => {
120
+ this.lastSelectionId = selectionEvent[0]?.id;
121
+ if (this.preservingCategoryFilter) {
122
+ return;
123
+ }
124
+ const node = selectionEvent[0];
125
+ const newId = node ? this.categoryIdForSelection(node) : undefined;
126
+ if (newId !== this._categoryFilterId) {
127
+ this._categoryFilterId = newId;
128
+ this.updateFilteredRows(PreferenceFilterChangeSource.Category);
129
+ }
130
+ }),
107
131
  this.treeGenerator.onSchemaChanged(newTree => this.handleNewSchema(newTree)),
108
132
  this.scopeTracker.onScopeChanged(scopeDetails => {
109
133
  this._currentScope = scopeDetails.scope;
110
134
  this.updateFilteredRows(PreferenceFilterChangeSource.Scope);
135
+ this.ensureSelectedCategoryHasVisibleSettings();
111
136
  }),
112
137
  this.filterInput.onFilterChanged(newSearchTerm => {
113
138
  this.lastSearchedTags = Array.from(newSearchTerm.matchAll(/@tag:([^\s]+)/g)).map(match => match[0].slice(5));
114
139
  const newSearchTermWithoutTags = newSearchTerm.replace(/@tag:[^\s]+/g, '');
115
140
  this.lastSearchedLiteral = newSearchTermWithoutTags;
116
141
  this.lastSearchedFuzzy = newSearchTermWithoutTags.replace(/\s/g, '');
142
+ const wasFiltered = this._isFiltered;
117
143
  this._isFiltered = newSearchTerm.length > 2;
118
- if (this.isFiltered) {
144
+ if (this._isFiltered) {
145
+ // Search results span all categories: drop the category filter and the
146
+ // now-stale selection so the tree matches the shown results.
147
+ this._categoryFilterId = undefined;
148
+ this.clearSelection();
119
149
  this.expandAll();
120
150
  } else if (CompositeTreeNode.is(this.root)) {
121
151
  const root = this.root;
122
152
  // Avoid intermediate selection events while collapsing.
123
153
  this.withSuppressedSelection(() => this.collapseAll(root));
154
+ if (wasFiltered) {
155
+ this.selectDefaultCategory();
156
+ }
124
157
  }
125
158
  this.updateFilteredRows(PreferenceFilterChangeSource.Search);
126
159
  }),
@@ -135,10 +168,91 @@ export class PreferenceTreeModel extends TreeModelImpl {
135
168
 
136
169
  private handleNewSchema(newRoot: CompositeTreeNode): void {
137
170
  this.root = newRoot;
171
+ if (this._categoryFilterId && !this.getNode(this._categoryFilterId)) {
172
+ this._categoryFilterId = undefined;
173
+ }
138
174
  if (this.isFiltered) {
139
175
  this.expandAll();
140
176
  }
141
177
  this.updateFilteredRows(PreferenceFilterChangeSource.Schema);
178
+ this.restoreSelectionInNewTree();
179
+ this.ensureSelectedCategoryHasVisibleSettings();
180
+ this.applyInitialSelection();
181
+ }
182
+
183
+ /**
184
+ * Re-applies the selection after the tree has been rebuilt with new node instances,
185
+ * e.g. when plugins contribute preferences after startup. The selection state resolves
186
+ * nodes by id, but the rebuilt node lacks the `selected` flag that drives the tree
187
+ * highlight. If the selected node is gone from the new schema, falls back to the
188
+ * default category.
189
+ */
190
+ protected restoreSelectionInNewTree(): void {
191
+ if (!this.lastSelectionId) {
192
+ return;
193
+ }
194
+ const node = this.getNode(this.lastSelectionId);
195
+ if (node && SelectableTreeNode.is(node)) {
196
+ if (!node.selected) {
197
+ this.selectPreservingCategoryFilter(node);
198
+ }
199
+ } else {
200
+ this.clearSelection();
201
+ this._initialSelectionApplied = false;
202
+ }
203
+ }
204
+
205
+ protected applyInitialSelection(): void {
206
+ if (this._initialSelectionApplied) {
207
+ return;
208
+ }
209
+ if (this.selectedNodes.length > 0) {
210
+ // Something is already selected, e.g. by the user before the schema change.
211
+ this._initialSelectionApplied = true;
212
+ return;
213
+ }
214
+ if (this._isFiltered) {
215
+ // A search (e.g. restored on startup) is active; the default selection is
216
+ // applied when it is cleared instead.
217
+ return;
218
+ }
219
+ if (this.selectDefaultCategory()) {
220
+ this._initialSelectionApplied = true;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Selects the 'Commonly Used' category, the default detail page of the settings editor.
226
+ * @returns `true` if the category was found and selected.
227
+ */
228
+ protected selectDefaultCategory(): boolean {
229
+ const commonlyUsed = this.getNode(COMMONLY_USED_NODE_ID);
230
+ if (commonlyUsed && SelectableTreeNode.is(commonlyUsed)) {
231
+ this.selectNode(commonlyUsed);
232
+ return true;
233
+ }
234
+ return false;
235
+ }
236
+
237
+ /**
238
+ * Falls back to the default category when the selected category has no settings left to
239
+ * show, e.g. after switching to a scope in which none of its settings are valid. Otherwise
240
+ * the editor would show an empty page for a category the tree no longer lists. Clears the
241
+ * selection instead if the default category has no settings to show either.
242
+ */
243
+ protected ensureSelectedCategoryHasVisibleSettings(): void {
244
+ if (!this._categoryFilterId || this.hasVisibleSettings(this._categoryFilterId)) {
245
+ return;
246
+ }
247
+ if (this.hasVisibleSettings(COMMONLY_USED_NODE_ID)) {
248
+ this.selectDefaultCategory();
249
+ } else {
250
+ this.clearSelection();
251
+ }
252
+ }
253
+
254
+ protected hasVisibleSettings(categoryId: string): boolean {
255
+ return !!this._currentRows.get(categoryId)?.visibleChildren;
142
256
  }
143
257
 
144
258
  protected updateRows(): void {
@@ -266,15 +380,42 @@ export class PreferenceTreeModel extends TreeModelImpl {
266
380
  }
267
381
 
268
382
  /**
383
+ * Returns the id of the nearest composite (category) ancestor of `node`,
384
+ * inclusive of `node` itself. Returns `undefined` if no category ancestor is found.
385
+ */
386
+ protected categoryIdForSelection(node: TreeNode): string | undefined {
387
+ let current: TreeNode | undefined = node;
388
+ while (current) {
389
+ if (Preference.TreeNode.is(current) && Preference.CompositeTreeNode.is(current)) {
390
+ return current.id;
391
+ }
392
+ current = current.parent;
393
+ }
394
+ return undefined;
395
+ }
396
+
397
+ /**
398
+ * Selects `node` to mirror the editor's scroll position in the tree. Unlike an explicit
399
+ * selection, this does not change the category filter: scrolling must never narrow the
400
+ * settings editor, only an explicit selection may.
269
401
  * @returns true if selection changed, false otherwise
270
402
  */
271
403
  selectIfNotSelected(node: SelectableTreeNode): boolean {
272
404
  const currentlySelected = this.selectedNodes[0];
273
405
  if (!node.selected || node !== currentlySelected) {
274
- node.selected = true;
275
- this.selectNode(node);
406
+ this.selectPreservingCategoryFilter(node);
276
407
  return true;
277
408
  }
278
409
  return false;
279
410
  }
411
+
412
+ protected selectPreservingCategoryFilter(node: SelectableTreeNode): void {
413
+ this.preservingCategoryFilter = true;
414
+ try {
415
+ node.selected = true;
416
+ this.selectNode(node);
417
+ } finally {
418
+ this.preservingCategoryFilter = false;
419
+ }
420
+ }
280
421
  }
@@ -219,6 +219,12 @@
219
219
  font-size: var(--theia-ui-font-size2);
220
220
  }
221
221
 
222
+ .theia-settings-container .theia-settings-pinned-category-header {
223
+ position: sticky;
224
+ z-index: 5;
225
+ background-color: var(--theia-editor-background);
226
+ }
227
+
222
228
  .theia-settings-container .settings-section>li {
223
229
  list-style-type: none;
224
230
  margin: var(--theia-ui-padding) 0px;
@@ -21,7 +21,6 @@ import * as deepEqual from 'fast-deep-equal';
21
21
  import {
22
22
  CompositeTreeNode,
23
23
  SelectableTreeNode,
24
- StatefulWidget,
25
24
  TopDownTreeIterator,
26
25
  ExpandableTreeNode,
27
26
  } from '@theia/core/lib/browser';
@@ -37,12 +36,8 @@ import { PreferencesScopeTabBar } from './preference-scope-tabbar-widget';
37
36
  import { PreferenceNodeRendererCreatorRegistry } from './components/preference-node-renderer-creator';
38
37
  import { COMMONLY_USED_SECTION_PREFIX } from '../util/preference-layout';
39
38
 
40
- export interface PreferencesEditorState {
41
- firstVisibleChildID: string,
42
- }
43
-
44
39
  @injectable()
45
- export class PreferencesEditorWidget extends BaseWidget implements StatefulWidget {
40
+ export class PreferencesEditorWidget extends BaseWidget {
46
41
  static readonly ID = 'settings.editor';
47
42
  static readonly LABEL = nls.localizeByDefault('Settings Editor');
48
43
 
@@ -138,6 +133,8 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
138
133
 
139
134
  if (e.source === PreferenceFilterChangeSource.Search) {
140
135
  this.handleSearchChange(isFiltered, leavesAreVisible);
136
+ } else if (e.source === PreferenceFilterChangeSource.Category) {
137
+ this.handleCategoryChange(isFiltered, leavesAreVisible);
141
138
  } else if (e.source === PreferenceFilterChangeSource.Scope) {
142
139
  this.handleScopeChange(isFiltered);
143
140
  } else if (e.source === PreferenceFilterChangeSource.Schema) {
@@ -146,10 +143,14 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
146
143
  unreachable(e.source, 'Not all PreferenceFilterChangeSource enum variants handled.');
147
144
  }
148
145
 
149
- if (scrollTarget) {
146
+ // Only sync the tree to a row that is still shown. A row hidden by the change, e.g. because
147
+ // it is not valid in the new scope or lies outside the selected category, must not pull the
148
+ // tree selection away from the page that is shown.
149
+ if (scrollTarget && this.isRendererVisible(scrollTarget)) {
150
150
  this.showInTree(scrollTarget);
151
151
  }
152
152
  this.resetScroll(scrollTarget);
153
+ this.pinCategoryHeaders();
153
154
 
154
155
  if (e.source === PreferenceFilterChangeSource.Search) {
155
156
  // Reset focus if search context changes.
@@ -158,6 +159,11 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
158
159
  }
159
160
 
160
161
  protected getScrollTarget(source: PreferenceFilterChangeSource): string | undefined {
162
+ if (source === PreferenceFilterChangeSource.Category) {
163
+ // A newly selected category always opens at its top. Scrolling back to the last focused
164
+ // control would re-select its (previous) category in the tree and desync tree and editor.
165
+ return undefined;
166
+ }
161
167
  if (source !== PreferenceFilterChangeSource.Search) {
162
168
  return this.firstVisibleChildID;
163
169
  }
@@ -167,6 +173,11 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
167
173
  return undefined;
168
174
  }
169
175
 
176
+ protected isRendererVisible(nodeId: string): boolean {
177
+ const { id, collection } = this.analyzeIDAndGetRendererGroup(nodeId);
178
+ return !!collection.get(id)?.visible;
179
+ }
180
+
170
181
  protected isRendererInViewport(nodeId: string): boolean {
171
182
  const { id, collection } = this.analyzeIDAndGetRendererGroup(nodeId);
172
183
  const renderer = collection.get(id);
@@ -243,6 +254,14 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
243
254
  }
244
255
  }
245
256
 
257
+ protected handleCategoryChange(isFiltered: boolean, leavesAreVisible: boolean): void {
258
+ if (leavesAreVisible) {
259
+ for (const [, renderer] of this.allRenderers()) {
260
+ this.hideIfFailsFilters(renderer, isFiltered);
261
+ }
262
+ }
263
+ }
264
+
246
265
  protected areLeavesVisible(): boolean {
247
266
  const leavesAreVisible = this.model.totalVisibleLeaves > 0;
248
267
  this.node.classList.toggle('no-results', !leavesAreVisible);
@@ -266,12 +285,30 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
266
285
  }
267
286
  }
268
287
 
288
+ /**
289
+ * Returns true if a category filter is active and `node` is outside the visible slice:
290
+ * not the selected category itself, not a descendant of it, and not one of its
291
+ * composite ancestors (whose headers we keep visible above the selected category).
292
+ * Keeps the left tree intact (filtering is applied here, not in the model).
293
+ */
294
+ protected isOutsideSelectedCategory(node: Preference.TreeNode): boolean {
295
+ const categoryId = this.model.categoryFilterId;
296
+ if (!categoryId || node.id === categoryId) {
297
+ return false;
298
+ }
299
+ const category = this.model.getNode(categoryId);
300
+ if (!CompositeTreeNode.is(category) || CompositeTreeNode.isAncestor(category, node)) {
301
+ return false;
302
+ }
303
+ return !(CompositeTreeNode.is(node) && CompositeTreeNode.isAncestor(node, category));
304
+ }
305
+
269
306
  /**
270
307
  * @returns true if the renderer is hidden, false otherwise.
271
308
  */
272
309
  protected hideIfFailsFilters(renderer: GeneralPreferenceNodeRenderer, isFiltered: boolean): boolean {
273
310
  const row = this.model.currentRows.get(renderer.nodeId);
274
- if (!row || (CompositeTreeNode.is(row.node) && (isFiltered || row.visibleChildren === 0))) {
311
+ if (!row || (CompositeTreeNode.is(row.node) && (isFiltered || row.visibleChildren === 0)) || this.isOutsideSelectedCategory(row.node)) {
275
312
  renderer.hide();
276
313
  return true;
277
314
  } else {
@@ -280,6 +317,69 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
280
317
  }
281
318
  }
282
319
 
320
+ /**
321
+ * Pins category header rows to the top of the scroll container via `position: sticky`.
322
+ * The headers of the selected category and its ancestors are stacked on top of each
323
+ * other; the headers of its subsections all share the single slot below that stack, so
324
+ * the subsection currently scrolled into view replaces the previous one instead of the
325
+ * headers piling up. Clears the styles when no category filter is active.
326
+ */
327
+ protected pinCategoryHeaders(): void {
328
+ requestAnimationFrame(() => {
329
+ const categoryId = this.model.categoryFilterId;
330
+ const stackedHeaders = this.getStackedHeaderRenderers();
331
+ // Read all heights before writing any styles, so that the layout is computed only once.
332
+ const heights = stackedHeaders.map(renderer => renderer.node.offsetHeight);
333
+ const stackedTops = new Map<string, number>();
334
+ let stackHeight = 0;
335
+ stackedHeaders.forEach((renderer, index) => {
336
+ stackedTops.set(renderer.nodeId, stackHeight);
337
+ stackHeight += heights[index];
338
+ });
339
+ for (const [, renderer] of this.allRenderers()) {
340
+ if (!CompositeTreeNode.is(this.model.getNode(renderer.nodeId))) {
341
+ continue;
342
+ }
343
+ const wrapper = renderer.node;
344
+ if (categoryId && renderer.visible) {
345
+ wrapper.classList.add('theia-settings-pinned-category-header');
346
+ wrapper.style.top = `${stackedTops.get(renderer.nodeId) ?? stackHeight}px`;
347
+ } else {
348
+ wrapper.classList.remove('theia-settings-pinned-category-header');
349
+ wrapper.style.top = '';
350
+ }
351
+ }
352
+ });
353
+ }
354
+
355
+ /**
356
+ * @returns the visible header renderers of the selected category and its ancestors,
357
+ * outermost first, i.e. in document order. Empty if no category filter is active.
358
+ */
359
+ protected getStackedHeaderRenderers(): GeneralPreferenceNodeRenderer[] {
360
+ const renderers: GeneralPreferenceNodeRenderer[] = [];
361
+ const categoryId = this.model.categoryFilterId;
362
+ if (!categoryId) {
363
+ return renderers;
364
+ }
365
+ for (let node = this.model.getNode(categoryId); node && Preference.TreeNode.is(node); node = node.parent) {
366
+ const { id, collection } = this.analyzeIDAndGetRendererGroup(node.id);
367
+ const renderer = collection.get(id);
368
+ if (renderer?.visible) {
369
+ renderers.unshift(renderer);
370
+ }
371
+ }
372
+ return renderers;
373
+ }
374
+
375
+ /**
376
+ * @returns the total height of the pinned header stack, i.e. the space at the top of the
377
+ * scroll container that is covered by the headers of the selected category and its ancestors.
378
+ */
379
+ protected getStackedHeaderHeight(stackedHeaders: GeneralPreferenceNodeRenderer[] = this.getStackedHeaderRenderers()): number {
380
+ return stackedHeaders.reduce((height, renderer) => height + renderer.node.offsetHeight, 0);
381
+ }
382
+
283
383
  protected resetScroll(nodeIDToScrollTo?: string): void {
284
384
  if (this.scrollBar) { // Absent on widget creation
285
385
  this.doResetScroll(nodeIDToScrollTo);
@@ -300,7 +400,8 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
300
400
  const { id, collection } = this.analyzeIDAndGetRendererGroup(nodeIDToScrollTo);
301
401
  const renderer = collection.get(id);
302
402
  if (renderer?.visible) {
303
- this.scrollContainer.scrollTo(0, renderer.node.offsetTop);
403
+ // Place the target right below the pinned category headers, not behind them.
404
+ this.scrollContainer.scrollTo(0, renderer.node.offsetTop - this.getStackedHeaderHeight());
304
405
  return;
305
406
  }
306
407
  }
@@ -324,10 +425,21 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
324
425
  onScroll = throttle(this.doOnScroll.bind(this), 50);
325
426
 
326
427
  protected findFirstVisibleChildID(): string | undefined {
327
- const { scrollTop } = this.scrollContainer;
428
+ // The pinned headers of the selected category and its ancestors always sit at the top of
429
+ // the scroll container. Skip them and look for the first row right below the stack,
430
+ // otherwise the outermost ancestor would always be reported and the tree selection would
431
+ // jump from the selected sub-category to its parent.
432
+ const stackedHeaders = this.getStackedHeaderRenderers();
433
+ const stackBottom = this.scrollContainer.scrollTop + this.getStackedHeaderHeight(stackedHeaders);
328
434
  for (const [, renderer] of this.allRenderers()) {
329
- const { offsetTop, offsetHeight } = renderer.node;
330
- if (Math.abs(offsetTop - scrollTop) <= offsetHeight / 2) {
435
+ const { offsetTop, offsetHeight, offsetParent } = renderer.node;
436
+ // Skip hidden renderers (display:none): they report offsetTop/offsetHeight 0
437
+ // and would otherwise match scrollTop===0, hijacking the selection back to
438
+ // the first hidden category.
439
+ if (!offsetParent || stackedHeaders.includes(renderer)) {
440
+ continue;
441
+ }
442
+ if (Math.abs(offsetTop - stackBottom) <= offsetHeight / 2) {
331
443
  return renderer.nodeId;
332
444
  }
333
445
  }
@@ -408,15 +520,4 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
408
520
  protected override getScrollContainer(): HTMLElement {
409
521
  return this.scrollContainer;
410
522
  }
411
-
412
- storeState(): PreferencesEditorState {
413
- return {
414
- firstVisibleChildID: this.firstVisibleChildID,
415
- };
416
- }
417
-
418
- restoreState(oldState: PreferencesEditorState): void {
419
- this.firstVisibleChildID = oldState.firstVisibleChildID;
420
- this.resetScroll(this.firstVisibleChildID);
421
- }
422
523
  }
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { postConstruct, injectable, inject } from '@theia/core/shared/inversify';
18
18
  import { Panel, Widget, Message, StatefulWidget, codicon } from '@theia/core/lib/browser';
19
- import { PreferencesEditorState, PreferencesEditorWidget } from './preference-editor-widget';
19
+ import { PreferencesEditorWidget } from './preference-editor-widget';
20
20
  import { PreferencesTreeWidget } from './preference-tree-widget';
21
21
  import { PreferencesSearchbarState, PreferencesSearchbarWidget } from './preference-searchbar-widget';
22
22
  import { PreferencesScopeTabBar, PreferencesScopeTabBarState } from './preference-scope-tabbar-widget';
@@ -27,7 +27,6 @@ import { PreferenceScope } from '@theia/core';
27
27
 
28
28
  interface PreferencesWidgetState {
29
29
  scopeTabBarState: PreferencesScopeTabBarState,
30
- editorState: PreferencesEditorState,
31
30
  searchbarWidgetState: PreferencesSearchbarState,
32
31
  }
33
32
 
@@ -106,14 +105,12 @@ export class PreferencesWidget extends Panel implements StatefulWidget {
106
105
  storeState(): PreferencesWidgetState {
107
106
  return {
108
107
  scopeTabBarState: this.tabBarWidget.storeState(),
109
- editorState: this.editorWidget.storeState(),
110
108
  searchbarWidgetState: this.searchbarWidget.storeState(),
111
109
  };
112
110
  }
113
111
 
114
112
  restoreState(state: PreferencesWidgetState): void {
115
113
  this.tabBarWidget.restoreState(state.scopeTabBarState);
116
- this.editorWidget.restoreState(state.editorState);
117
114
  this.searchbarWidget.restoreState(state.searchbarWidgetState);
118
115
  }
119
116
  }