From ef73ddc15b84c3d0d0776c0d7e8973db5b9d6a63 Mon Sep 17 00:00:00 2001 From: egocalr Date: Tue, 28 Jul 2020 17:21:28 -0500 Subject: [PATCH] Improve Preference Filtering Signed-off-by: Colin Grant --- .../src/browser/preference-frontend-module.ts | 4 - .../src/browser/preference-tree-model.ts | 192 ++++++++++++++++++ .../src/browser/preference-tree-provider.ts | 155 -------------- .../src/browser/preferences-contribution.ts | 41 ++-- .../browser/util/preference-event-service.ts | 27 --- .../util/preference-tree-generator.spec.ts | 7 +- .../browser/util/preference-tree-generator.ts | 48 +++-- .../src/browser/util/preference-types.ts | 61 ++---- .../single-preference-display-factory.tsx | 17 +- .../components/single-preference-wrapper.tsx | 38 +--- .../views/preference-editor-widget.tsx | 192 ++++++++++-------- .../views/preference-scope-tabbar-widget.tsx | 62 +++--- .../views/preference-searchbar-widget.tsx | 13 +- .../browser/views/preference-tree-widget.tsx | 142 ++----------- .../views/preference-widget-bindings.ts | 65 ++---- .../src/browser/views/preference-widget.tsx | 39 ++-- 16 files changed, 475 insertions(+), 628 deletions(-) create mode 100644 packages/preferences/src/browser/preference-tree-model.ts delete mode 100644 packages/preferences/src/browser/preference-tree-provider.ts delete mode 100644 packages/preferences/src/browser/util/preference-event-service.ts diff --git a/packages/preferences/src/browser/preference-frontend-module.ts b/packages/preferences/src/browser/preference-frontend-module.ts index c204e0a543886..c08a71945dc09 100644 --- a/packages/preferences/src/browser/preference-frontend-module.ts +++ b/packages/preferences/src/browser/preference-frontend-module.ts @@ -22,8 +22,6 @@ import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar import { PreferenceTreeGenerator } from './util/preference-tree-generator'; import { bindPreferenceProviders } from './preference-bindings'; import { bindPreferencesWidgets } from './views/preference-widget-bindings'; -import { PreferencesEventService } from './util/preference-event-service'; -import { PreferencesTreeProvider } from './preference-tree-provider'; import { PreferencesContribution } from './preferences-contribution'; import { PreferenceScopeCommandManager } from './util/preference-scope-command-manager'; import { JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-store'; @@ -33,8 +31,6 @@ export function bindPreferences(bind: interfaces.Bind, unbind: interfaces.Unbind bindPreferenceProviders(bind, unbind); bindPreferencesWidgets(bind); - bind(PreferencesEventService).toSelf().inSingletonScope(); - bind(PreferencesTreeProvider).toSelf().inSingletonScope(); bind(PreferenceTreeGenerator).toSelf().inSingletonScope(); bindViewContribution(bind, PreferencesContribution); diff --git a/packages/preferences/src/browser/preference-tree-model.ts b/packages/preferences/src/browser/preference-tree-model.ts new file mode 100644 index 0000000000000..fec97c9f0d846 --- /dev/null +++ b/packages/preferences/src/browser/preference-tree-model.ts @@ -0,0 +1,192 @@ +/******************************************************************************** + * Copyright (C) 2020 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable, postConstruct } from 'inversify'; +import { + TreeModelImpl, + TreeWidget, + CompositeTreeNode, + TopDownTreeIterator, + TreeNode, + PreferenceSchemaProvider, + PreferenceDataProperty, + NodeProps, + ExpandableTreeNode +} from '@theia/core/lib/browser'; +import { Emitter } from '@theia/core'; +import { PreferencesSearchbarWidget } from './views/preference-searchbar-widget'; +import { PreferenceTreeGenerator } from './util/preference-tree-generator'; +import * as fuzzy from 'fuzzy'; +import { PreferencesScopeTabBar } from './views/preference-scope-tabbar-widget'; +import { Preference } from './util/preference-types'; +import { Event } from '@theia/core/src/common'; + +export interface PreferenceTreeNodeRow extends TreeWidget.NodeRow { + visibleChildren: number; + isExpansible?: boolean; +} +export interface PreferenceTreeNodeProps extends NodeProps { + visibleChildren: number; + isExpansible?: boolean; +} + +@injectable() +export class PreferenceTreeModel extends TreeModelImpl { + + @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; + @inject(PreferencesSearchbarWidget) protected readonly filterInput: PreferencesSearchbarWidget; + @inject(PreferenceTreeGenerator) protected readonly treeGenerator: PreferenceTreeGenerator; + @inject(PreferencesScopeTabBar) protected readonly scopeTracker: PreferencesScopeTabBar; + + protected readonly onTreeFilterChangedEmitter = new Emitter<{ filterCleared: boolean; rows: Map; }>(); + readonly onFilterChanged = this.onTreeFilterChangedEmitter.event; + + protected lastSearchedFuzzy: string = ''; + protected lastSearchedLiteral: string = ''; + protected _currentScope: number = Number(Preference.DEFAULT_SCOPE.scope); + protected _isFiltered: boolean = false; + protected _currentRows: Map = new Map(); + protected _totalVisibleLeaves = 0; + + get currentRows(): Readonly> { + return this._currentRows; + } + + get totalVisibleLeaves(): number { + return this._totalVisibleLeaves; + } + + get isFiltered(): boolean { + return this._isFiltered; + } + + get propertyList(): { [key: string]: PreferenceDataProperty; } { + return this.schemaProvider.getCombinedSchema().properties; + } + + get currentScope(): Preference.SelectedScopeDetails { + return this.scopeTracker.currentScope; + } + + get onSchemaChanged(): Event { + return this.treeGenerator.onSchemaChanged; + } + + @postConstruct() + protected init(): void { + super.init(); + this.toDispose.pushAll([ + this.treeGenerator.onSchemaChanged(newTree => { + this.root = newTree; + this.updateFilteredRows(); + }), + this.scopeTracker.onScopeChanged(scopeDetails => { + this._currentScope = Number(scopeDetails.scope); + this.updateFilteredRows(); + }), + this.filterInput.onFilterChanged(newSearchTerm => { + this.lastSearchedLiteral = newSearchTerm; + this.lastSearchedFuzzy = newSearchTerm.replace(/\s/g, ''); + const wasFiltered = this._isFiltered; + this._isFiltered = newSearchTerm.length > 2; + this.updateFilteredRows(wasFiltered && !this._isFiltered); + }), + this.onTreeFilterChangedEmitter, + ]); + } + + protected updateRows(): void { + const root = this.root; + this._currentRows = new Map(); + if (root) { + this._totalVisibleLeaves = 0; + const depths = new Map(); + let index = 0; + + for (const node of new TopDownTreeIterator(root, { + pruneCollapsed: false, + pruneSiblings: true + })) { + if (TreeNode.isVisible(node)) { + if (CompositeTreeNode.is(node) || this.passesCurrentFilters(node.id)) { + const depth = this.getDepthForNode(depths, node); + + this.updateVisibleChildren(node); + + this._currentRows.set(node.id, { + index: index++, + node, + depth, + visibleChildren: 0, + }); + } + } + } + } + } + + protected updateFilteredRows(filterWasCleared: boolean = false): void { + this.updateRows(); + this.onTreeFilterChangedEmitter.fire({ filterCleared: filterWasCleared, rows: this._currentRows }); + } + + protected passesCurrentFilters(nodeID: string): boolean { + const currentNodeShouldBeVisible = this.schemaProvider.isValidInScope(nodeID, this._currentScope) + && ( + !this._isFiltered // search too short. + || fuzzy.test(this.lastSearchedFuzzy, nodeID || '') // search matches preference name. + // search matches description. Fuzzy isn't ideal here because the score depends on the order of discovery. + || (this.schemaProvider.getCombinedSchema().properties[nodeID].description || '').includes(this.lastSearchedLiteral) + ); + + return currentNodeShouldBeVisible; + } + + protected getDepthForNode(depths: Map, node: TreeNode): number { + const parentDepth = depths.get(node.parent); + const depth = parentDepth === undefined ? 0 : TreeNode.isVisible(node.parent) ? parentDepth + 1 : parentDepth; + if (CompositeTreeNode.is(node)) { + depths.set(node, depth); + } + return depth; + } + + protected updateVisibleChildren(node: TreeNode): void { + if (!CompositeTreeNode.is(node)) { + this._totalVisibleLeaves++; + let nextParent = node.parent?.id && this._currentRows.get(node.parent?.id); + while (nextParent && nextParent.node !== this.root) { + if (nextParent) { + nextParent.visibleChildren += 1; + } + nextParent = nextParent.node.parent?.id && this._currentRows.get(nextParent.node.parent?.id); + if (nextParent) { + nextParent.isExpansible = true; + } + } + } + } + + collapseAllExcept(openNode: ExpandableTreeNode | undefined): void { + this.expandNode(openNode); + const children = (this.root as CompositeTreeNode).children as ExpandableTreeNode[]; + children.forEach(child => { + if (child !== openNode && child.expanded) { + this.collapseNode(child); + } + }); + } +} diff --git a/packages/preferences/src/browser/preference-tree-provider.ts b/packages/preferences/src/browser/preference-tree-provider.ts deleted file mode 100644 index 5e30d35f6d5f6..0000000000000 --- a/packages/preferences/src/browser/preference-tree-provider.ts +++ /dev/null @@ -1,155 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2020 Ericsson and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ - -import { inject, injectable, postConstruct } from 'inversify'; -import * as fuzzy from 'fuzzy'; -import { debounce } from 'lodash'; -import { TreeNode, CompositeTreeNode, PreferenceSchemaProvider, PreferenceDataSchema, PreferenceDataProperty, PreferenceScope } from '@theia/core/lib/browser'; -import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; -import { PreferencesEventService } from './util/preference-event-service'; -import { PreferenceTreeGenerator } from './util/preference-tree-generator'; -import { Preference } from './util/preference-types'; - -interface PreferenceFilterOptions { - minLength?: number; - baseSchemaAltered?: boolean; - requiresFilter?: boolean; -}; - -const filterDefaults: Required = { - minLength: 1, - baseSchemaAltered: false, - requiresFilter: true, -}; - -@injectable() -export class PreferencesTreeProvider { - - protected _isFiltered: boolean = false; - protected lastSearchedLiteral: string = ''; - protected lastSearchedFuzzy: string = ''; - protected baseSchema: PreferenceDataSchema; - protected baseTree: CompositeTreeNode; - protected _currentTree: CompositeTreeNode; - protected currentScope: Preference.SelectedScopeDetails = Preference.DEFAULT_SCOPE; - protected handleUnderlyingDataChange = debounce( - (options: PreferenceFilterOptions, newScope?: Preference.SelectedScopeDetails) => this.updateUnderlyingData(options, newScope), - 200 - ); - - @inject(PreferencesEventService) protected readonly preferencesEventService: PreferencesEventService; - @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; - @inject(PreferenceConfigurations) protected readonly preferenceConfigs: PreferenceConfigurations; - @inject(PreferenceTreeGenerator) protected readonly preferencesTreeGenerator: PreferenceTreeGenerator; - - @postConstruct() - protected init(): void { - this.updateUnderlyingData({ baseSchemaAltered: true }); - this.schemaProvider.onDidPreferenceSchemaChanged(() => this.handleUnderlyingDataChange({ baseSchemaAltered: true })); - this.preferencesEventService.onSearch.event(searchEvent => this.updateDisplay(searchEvent.query)); - this.preferencesEventService.onTabScopeSelected.event(scopeEvent => { - const newScope = Number(scopeEvent.scope); - const currentScope = Number(this.currentScope.scope); - const scopeChangesPreferenceVisibility = - ((newScope === PreferenceScope.User || newScope === PreferenceScope.Workspace) && currentScope === PreferenceScope.Folder) - || (newScope === PreferenceScope.Folder && (currentScope === PreferenceScope.User || currentScope === PreferenceScope.Workspace)); - - this.handleUnderlyingDataChange({ requiresFilter: scopeChangesPreferenceVisibility }, scopeEvent); - }); - } - - protected updateUnderlyingData(options: PreferenceFilterOptions, newScope?: Preference.SelectedScopeDetails): void { - if (options.baseSchemaAltered) { - this.baseSchema = this.schemaProvider.getCombinedSchema(); - } - if (newScope) { - this.currentScope = newScope; - } - this.updateDisplay(this.lastSearchedLiteral, options); - } - - protected updateDisplay(term: string = this.lastSearchedLiteral, options: PreferenceFilterOptions = {}): void { - if (options.baseSchemaAltered) { - this.baseTree = this.preferencesTreeGenerator.generateTree(); - } - const shouldBuildNewTree = options.requiresFilter !== false; - if (shouldBuildNewTree) { - this._currentTree = this.filter(term, Number(this.currentScope.scope), this.baseTree, options); - } - - this.preferencesEventService.onDisplayChanged.fire(shouldBuildNewTree || !!options.baseSchemaAltered); - } - - protected filter( - searchTerm: string, - currentScope: PreferenceScope, - tree: Tree, - filterOptions: PreferenceFilterOptions = {}, - ): Tree { - const { minLength } = { ...filterDefaults, ...filterOptions }; - - this.lastSearchedLiteral = searchTerm; - this.lastSearchedFuzzy = searchTerm.replace(/\s/g, ''); - this._isFiltered = searchTerm.length >= minLength; - - return this.recurseAndSetVisible(currentScope, tree); - } - - protected recurseAndSetVisible( - scope: PreferenceScope, - tree: Tree, - ): Tree { - let currentNodeShouldBeVisible = false; - - if (CompositeTreeNode.is(tree)) { - tree.children = tree.children.map(child => { - const newChild = this.recurseAndSetVisible(scope, child); - currentNodeShouldBeVisible = currentNodeShouldBeVisible || !!newChild.visible; - return newChild; - }); - if (Preference.Branch.is(tree)) { - tree.leaves = (tree.leaves || []).map(child => { - const newChild = this.recurseAndSetVisible(scope, child); - currentNodeShouldBeVisible = currentNodeShouldBeVisible || !!newChild.visible; - return newChild; - }); - } - } else { - currentNodeShouldBeVisible = this.schemaProvider.isValidInScope(tree.id, scope) - && ( - !this._isFiltered // search too short. - || fuzzy.test(this.lastSearchedFuzzy, tree.id || '') // search matches preference name. - // search matches description. Fuzzy isn't ideal here because the score depends on the order of discovery. - || (this.baseSchema.properties[tree.id].description || '').includes(this.lastSearchedLiteral) - ); - } - - return { ...tree, visible: currentNodeShouldBeVisible }; - } - - get currentTree(): CompositeTreeNode { - return this._currentTree; - } - - get propertyList(): { [key: string]: PreferenceDataProperty; } { - return this.baseSchema.properties; - } - - get isFiltered(): boolean { - return this._isFiltered; - } - -} diff --git a/packages/preferences/src/browser/preferences-contribution.ts b/packages/preferences/src/browser/preferences-contribution.ts index f6391f2fb692b..4007c68e90d11 100644 --- a/packages/preferences/src/browser/preferences-contribution.ts +++ b/packages/preferences/src/browser/preferences-contribution.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject, postConstruct, named } from 'inversify'; +import { injectable, inject, named } from 'inversify'; import { MenuModelRegistry, CommandRegistry } from '@theia/core'; import { CommonMenus, @@ -25,7 +25,6 @@ import { PreferenceScope, PreferenceProvider, PreferenceService, - PreferenceItem } from '@theia/core/lib/browser'; import { isFirefox } from '@theia/core/lib/browser'; import { isOSX } from '@theia/core/lib/common/os'; @@ -33,7 +32,6 @@ import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-too import { EditorManager, EditorWidget } from '@theia/editor/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { PreferencesWidget } from './views/preference-widget'; -import { PreferencesEventService } from './util/preference-event-service'; import { WorkspacePreferenceProvider } from './workspace-preference-provider'; import { Preference, PreferencesCommands, PreferenceMenus } from './util/preference-types'; import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; @@ -42,14 +40,12 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service'; @injectable() export class PreferencesContribution extends AbstractViewContribution { - @inject(PreferencesEventService) protected readonly preferencesEventService: PreferencesEventService; @inject(FileService) protected readonly fileService: FileService; @inject(PreferenceProvider) @named(PreferenceScope.Workspace) protected readonly workspacePreferenceProvider: WorkspacePreferenceProvider; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(PreferenceService) protected readonly preferenceService: PreferenceService; @inject(ClipboardService) protected readonly clipboardService: ClipboardService; - - protected preferencesScope = Preference.DEFAULT_SCOPE; + @inject(PreferencesWidget) protected readonly scopeTracker: PreferencesWidget; constructor() { super({ @@ -61,15 +57,6 @@ export class PreferencesContribution extends AbstractViewContribution { - const widget: PreferencesWidget = await this.widget; - this.preferencesScope = e; - widget.preferenceScope = this.preferencesScope; - }); - } - registerCommands(commands: CommandRegistry): void { commands.registerCommand(CommonCommands.OPEN_PREFERENCES, { execute: () => this.openView({ reveal: true }), @@ -91,7 +78,7 @@ export class PreferencesContribution extends AbstractViewContribution { + execute: ({ id, value }: { id: string, value: string; }) => { const jsonString = `"${id}": ${JSON.stringify(value)}`; this.clipboardService.writeText(jsonString); } @@ -99,8 +86,8 @@ export class PreferencesContribution extends AbstractViewContribution { - this.preferenceService.set(id, undefined, Number(this.preferencesScope.scope), this.preferencesScope.uri); + execute: ({ id }: Preference.EditorCommandArgs) => { + this.preferenceService.set(id, undefined, Number(this.scopeTracker.currentScope.scope), this.scopeTracker.currentScope.uri); } }); } @@ -151,18 +138,18 @@ export class PreferencesContribution extends AbstractViewContribution { const wasOpenedFromEditor = preferenceNode.constructor !== PreferencesWidget; - const { scope, activeScopeIsFolder, uri } = this.preferencesScope; + const { scope, activeScopeIsFolder, uri } = this.scopeTracker.currentScope; + const scopeID = Number(scope); const preferenceId = wasOpenedFromEditor ? preferenceNode.id : ''; // when opening from toolbar, widget is passed as arg by default (we don't need this info) - if (wasOpenedFromEditor) { - const currentPreferenceValue = preferenceNode.preference.values!; - const key = Preference.LookupKeys[Number(scope)] as keyof Preference.ValuesInAllScopes; - const valueInCurrentScope = currentPreferenceValue[key] === undefined ? currentPreferenceValue.defaultValue : currentPreferenceValue[key] as PreferenceItem; - this.preferenceService.set(preferenceId, valueInCurrentScope, Number(scope), uri); + if (wasOpenedFromEditor && preferenceNode.preference.values) { + const currentPreferenceValue = preferenceNode.preference.values; + const valueInCurrentScope = Preference.getValueInScope(currentPreferenceValue, scopeID) ?? currentPreferenceValue.defaultValue; + this.preferenceService.set(preferenceId, valueInCurrentScope, scopeID, uri); } let jsonEditorWidget: EditorWidget; - const jsonUriToOpen = await this.obtainConfigUri(scope, activeScopeIsFolder, uri); + const jsonUriToOpen = await this.obtainConfigUri(scopeID, activeScopeIsFolder, uri); if (jsonUriToOpen) { jsonEditorWidget = await this.editorManager.open(jsonUriToOpen); @@ -177,8 +164,8 @@ export class PreferencesContribution extends AbstractViewContribution { - let scope: PreferenceScope = Number(serializedScope); + private async obtainConfigUri(serializedScope: number, activeScopeIsFolder: string, resource: string): Promise { + let scope: PreferenceScope = serializedScope; if (activeScopeIsFolder === 'true') { scope = PreferenceScope.Folder; } diff --git a/packages/preferences/src/browser/util/preference-event-service.ts b/packages/preferences/src/browser/util/preference-event-service.ts deleted file mode 100644 index b48dbc35910ff..0000000000000 --- a/packages/preferences/src/browser/util/preference-event-service.ts +++ /dev/null @@ -1,27 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2020 Ericsson and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * This Source Code may also be made available under the following Secondary - * Licenses when the conditions for such availability set forth in the Eclipse - * Public License v. 2.0 are satisfied: GNU General Public License, version 2 - * with the GNU Classpath Exception which is available at - * https://www.gnu.org/software/classpath/license.html. - * - * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 - ********************************************************************************/ -import { injectable } from 'inversify'; -import { Emitter } from '@theia/core/lib/common/event'; -import { Preference } from './preference-types'; - -@injectable() -export class PreferencesEventService { - onTabScopeSelected = new Emitter(); - onSearch = new Emitter(); - onEditorScroll = new Emitter(); - onNavTreeSelection = new Emitter(); - onDisplayChanged = new Emitter(); -} diff --git a/packages/preferences/src/browser/util/preference-tree-generator.spec.ts b/packages/preferences/src/browser/util/preference-tree-generator.spec.ts index 98696d65c1204..e3ceeca60c4ca 100644 --- a/packages/preferences/src/browser/util/preference-tree-generator.spec.ts +++ b/packages/preferences/src/browser/util/preference-tree-generator.spec.ts @@ -28,9 +28,8 @@ FrontendApplicationConfigProvider.set({ import { expect } from 'chai'; import { Container } from 'inversify'; import { PreferenceTreeGenerator } from './preference-tree-generator'; -import { PreferenceSchemaProvider } from '@theia/core/lib/browser'; +import { CompositeTreeNode, PreferenceSchemaProvider } from '@theia/core/lib/browser'; import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; -import { Preference } from './preference-types'; disableJSDOM(); @@ -40,7 +39,7 @@ describe('preference-tree-generator', () => { beforeEach(() => { const container = new Container(); - container.bind(PreferenceSchemaProvider).toConstantValue(undefined); + container.bind(PreferenceSchemaProvider).toConstantValue({ onDidPreferenceSchemaChanged: () => { } }); container.bind(PreferenceConfigurations).toConstantValue(undefined); preferenceTreeGenerator = container.resolve(PreferenceTreeGenerator); }); @@ -71,7 +70,7 @@ describe('preference-tree-generator', () => { }); function testLeafName(property: string, expectedName: string): void { - const preferencesGroups: Preference.Branch[] = []; + const preferencesGroups: CompositeTreeNode[] = []; const root = preferenceTreeGenerator['createRootNode'](preferencesGroups); const preferencesGroup = preferenceTreeGenerator['createPreferencesGroup']('group', root); diff --git a/packages/preferences/src/browser/util/preference-tree-generator.ts b/packages/preferences/src/browser/util/preference-tree-generator.ts index a6b23ced31ed0..ea233fe66c6a5 100644 --- a/packages/preferences/src/browser/util/preference-tree-generator.ts +++ b/packages/preferences/src/browser/util/preference-tree-generator.ts @@ -14,22 +14,31 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable } from 'inversify'; +import { inject, injectable, postConstruct } from 'inversify'; import { CompositeTreeNode, PreferenceSchemaProvider, SelectableTreeNode } from '@theia/core/lib/browser'; import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; -import { Preference } from './preference-types'; +import { Emitter } from '@theia/core'; +import { debounce } from 'lodash'; @injectable() export class PreferenceTreeGenerator { - @inject(PreferenceSchemaProvider) schemaProvider: PreferenceSchemaProvider; - @inject(PreferenceConfigurations) preferenceConfigs: PreferenceConfigurations; + @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; + @inject(PreferenceConfigurations) protected readonly preferenceConfigs: PreferenceConfigurations; - generateTree(): CompositeTreeNode { + protected readonly onSchemaChangedEmitter = new Emitter(); + readonly onSchemaChanged = this.onSchemaChangedEmitter.event; + + @postConstruct() + protected init(): void { + this.schemaProvider.onDidPreferenceSchemaChanged(() => this.handleChangedSchema()); + } + + generateTree = (): CompositeTreeNode => { const preferencesSchema = this.schemaProvider.getCombinedSchema(); const propertyNames = Object.keys(preferencesSchema.properties).sort((a, b) => a.localeCompare(b)); - const preferencesGroups: Preference.Branch[] = []; - const groups = new Map(); + const preferencesGroups: CompositeTreeNode[] = []; + const groups = new Map(); const propertyPattern = Object.keys(preferencesSchema.patternProperties)[0]; // TODO: there may be a better way to get this data. const overridePropertyIdentifier = new RegExp(propertyPattern, 'i'); @@ -46,21 +55,28 @@ export class PreferenceTreeGenerator { preferencesGroups.push(parentPreferencesGroup); } if (subgroup && !groups.has(subgroup)) { - const remoteParent = groups.get(group) as Preference.Branch; + const remoteParent = groups.get(group) as CompositeTreeNode; const newBranch = this.createPreferencesGroup(subgroup, remoteParent); groups.set(subgroup, newBranch); CompositeTreeNode.addChild(remoteParent, newBranch); } - const parent = groups.get(subgroup || group) as Preference.Branch; + const parent = groups.get(subgroup || group) as CompositeTreeNode; const leafNode = this.createLeafNode(propertyName, parent); - parent.leaves.push(leafNode); + CompositeTreeNode.addChild(parent, leafNode); + // parent.leaves.push(leafNode); } } return root; }; - protected createRootNode = (preferencesGroups: Preference.Branch[]): CompositeTreeNode => ({ + doHandleChangedSchema(): void { + this.onSchemaChangedEmitter.fire(this.generateTree()); + } + + handleChangedSchema = debounce(this.doHandleChangedSchema, 200); + + protected createRootNode = (preferencesGroups: CompositeTreeNode[]): CompositeTreeNode => ({ id: 'root-node-id', name: '', parent: undefined, @@ -68,7 +84,7 @@ export class PreferenceTreeGenerator { children: preferencesGroups }); - protected createLeafNode = (property: string, preferencesGroup: Preference.Branch): SelectableTreeNode => { + protected createLeafNode = (property: string, preferencesGroup: CompositeTreeNode): SelectableTreeNode => { const rawLeaf = property.split('.').pop(); const name = this.formatString(rawLeaf!); return { @@ -80,20 +96,22 @@ export class PreferenceTreeGenerator { }; }; - protected createPreferencesGroup = (group: string, root: CompositeTreeNode): Preference.Branch => { + protected createPreferencesGroup = (group: string, root: CompositeTreeNode): CompositeTreeNode => { const isSubgroup = 'expanded' in root; const [groupname, subgroupname] = group.split('.'); const label = isSubgroup ? subgroupname : groupname; const newNode = { - id: `${group}-id`, + id: group, name: this.toTitleCase(label), visible: true, parent: root, children: [], - leaves: [], expanded: false, selected: false, }; + if (isSubgroup) { + delete newNode.expanded; + } return newNode; }; diff --git a/packages/preferences/src/browser/util/preference-types.ts b/packages/preferences/src/browser/util/preference-types.ts index fe1985752381c..e72ec9f4923fc 100644 --- a/packages/preferences/src/browser/util/preference-types.ts +++ b/packages/preferences/src/browser/util/preference-types.ts @@ -14,34 +14,13 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { PreferenceDataProperty, SelectableTreeNode, PreferenceItem, Title, PreferenceScope, TreeNode, CompositeTreeNode, ExpandableTreeNode } from '@theia/core/lib/browser'; +import { PreferenceDataProperty, PreferenceItem, Title, PreferenceScope, TreeNode } from '@theia/core/lib/browser'; import { Command, MenuPath } from '@theia/core'; export namespace Preference { - interface MaximalTree extends SelectableTreeNode, ExpandableTreeNode { - leaves: TreeExtension[]; - } - - export type TreeExtension = TreeNode & { - expanded?: MaximalTree['expanded']; - selected?: MaximalTree['selected']; - focus?: MaximalTree['focus']; - children?: MaximalTree['children']; - leaves?: MaximalTree['leaves']; - }; - - export interface Branch extends CompositeTreeNode { - leaves: TreeExtension[]; - } - - export namespace Branch { - export function is(node: TreeNode | Branch): node is Branch { - return 'leaves' in node && Array.isArray(node.leaves); - } - } export interface ValueInSingleScope { value?: PreferenceItem, data: PreferenceDataProperty; } - export interface NodeWithValueInSingleScope extends SelectableTreeNode { + export interface NodeWithValueInSingleScope extends TreeNode { preference: ValueInSingleScope; } @@ -69,16 +48,25 @@ export namespace Preference { } } - export interface NodeWithValueInAllScopes extends SelectableTreeNode { + export interface NodeWithValueInAllScopes extends TreeNode { preference: PreferenceWithValueInAllScopes; } - export enum LookupKeys { - defaultValue, - globalValue, - workspaceValue, - workspaceFolderValue, - } + export const getValueInScope = (preferenceInfo: ValuesInAllScopes | undefined, scope: number): PreferenceItem | undefined => { + if (!preferenceInfo) { + return undefined; + } + switch (scope) { + case PreferenceScope.User: + return preferenceInfo.globalValue; + case PreferenceScope.Workspace: + return preferenceInfo.workspaceValue; + case PreferenceScope.Folder: + return preferenceInfo.workspaceFolderValue; + default: + return undefined; + } + }; export interface SelectedScopeDetails extends Title.Dataset { scope: string; @@ -92,19 +80,6 @@ export namespace Preference { activeScopeIsFolder: 'false' }; - export interface SearchQuery { - query: string; - }; - - export interface MouseScrollDetails { - firstVisibleChildId: string; - isTop: boolean; - }; - - export interface SelectedTreeNode { - nodeID: string; - } - export interface ContextMenuCallbacks { resetCallback(): void; copyIDCallback(): void; diff --git a/packages/preferences/src/browser/views/components/single-preference-display-factory.tsx b/packages/preferences/src/browser/views/components/single-preference-display-factory.tsx index 0a075415fa709..c0e91cbcf9fa4 100644 --- a/packages/preferences/src/browser/views/components/single-preference-display-factory.tsx +++ b/packages/preferences/src/browser/views/components/single-preference-display-factory.tsx @@ -15,26 +15,19 @@ ********************************************************************************/ import * as React from 'react'; -import { injectable, inject, postConstruct } from 'inversify'; +import { injectable, inject } from 'inversify'; import { PreferenceService, ContextMenuRenderer } from '@theia/core/lib/browser'; import { CommandService } from '@theia/core'; import { Preference, PreferencesCommands } from '../../util/preference-types'; -import { PreferencesEventService } from '../../util/preference-event-service'; -import { PreferenceScopeCommandManager } from '../../util/preference-scope-command-manager'; import { SinglePreferenceWrapper } from './single-preference-wrapper'; +import { PreferencesScopeTabBar } from '../preference-scope-tabbar-widget'; @injectable() export class SinglePreferenceDisplayFactory { - protected currentScope: Preference.SelectedScopeDetails = Preference.DEFAULT_SCOPE; - @inject(PreferencesEventService) protected readonly preferencesEventService: PreferencesEventService; @inject(PreferenceService) protected readonly preferenceValueRetrievalService: PreferenceService; - @inject(PreferenceScopeCommandManager) protected readonly preferencesMenuFactory: PreferenceScopeCommandManager; @inject(CommandService) protected readonly commandService: CommandService; @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; - @postConstruct() - init(): void { - this.preferencesEventService.onTabScopeSelected.event(e => this.currentScope = e); - } + @inject(PreferencesScopeTabBar) protected readonly scopeTracker: PreferencesScopeTabBar; protected openJSON = (preferenceNode: Preference.NodeWithValueInAllScopes): void => { this.commandService.executeCommand(PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR.id, preferenceNode); @@ -44,8 +37,8 @@ export class SinglePreferenceDisplayFactory { return { - const matchingScope = PreferenceScope[scope]; - if (currentScope !== matchingScope) { + const otherScope = PreferenceScope[scope]; + if (currentScope !== otherScope) { const info = service.inspect(id); if (!info) { return; } const defaultValue = info.defaultValue; - const currentValue = this.getValueByScope(info, currentScope); - const matchingValue = this.getValueByScope(info, matchingScope); - - if (matchingValue !== undefined) { + const currentValue = Preference.getValueInScope(info, currentScope); + const otherValue = Preference.getValueInScope(info, otherScope); + if (otherValue !== undefined && otherValue !== defaultValue) { const bothOverridden = ( (currentValue !== defaultValue && currentValue !== undefined) && - (matchingValue !== defaultValue && matchingValue !== undefined) + (otherValue !== defaultValue && otherValue !== undefined) ); const message = bothOverridden ? 'Also modified in:' : 'Modified in:'; @@ -166,21 +157,6 @@ export class SinglePreferenceWrapper extends React.Component { this.props.preferencesService.set(preferenceName, preferenceValue, this.props.currentScope, this.props.currentScopeURI); diff --git a/packages/preferences/src/browser/views/preference-editor-widget.tsx b/packages/preferences/src/browser/views/preference-editor-widget.tsx index 8f7cec6b215fd..3fec35cd37a13 100644 --- a/packages/preferences/src/browser/views/preference-editor-widget.tsx +++ b/packages/preferences/src/browser/views/preference-editor-widget.tsx @@ -17,39 +17,45 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { postConstruct, injectable, inject } from 'inversify'; import * as React from 'react'; +import { debounce } from 'lodash'; import { Disposable } from 'vscode-jsonrpc'; import { ReactWidget, PreferenceService, - PreferenceDataProperty, - PreferenceScope, CompositeTreeNode, SelectableTreeNode, PreferenceItem, + TreeNode, + ExpandableTreeNode, } from '@theia/core/lib/browser'; import { Message, } from '@theia/core/lib/browser/widgets/widget'; import { SinglePreferenceDisplayFactory } from './components/single-preference-display-factory'; -import { Preference } from '../util/preference-types'; -import { PreferencesEventService } from '../util/preference-event-service'; -import { PreferencesTreeProvider } from '../preference-tree-provider'; +import { PreferenceTreeModel, PreferenceTreeNodeRow } from '../preference-tree-model'; +import { Emitter } from '@theia/core'; + +const HEADER_CLASS = 'settings-section-category-title'; +const SUBHEADER_CLASS = 'settings-section-subcategory-title'; @injectable() export class PreferencesEditorWidget extends ReactWidget { static readonly ID = 'settings.editor'; static readonly LABEL = 'Settings Editor'; - protected properties: { [key: string]: PreferenceDataProperty; }; - protected currentDisplay: CompositeTreeNode; - protected activeScope: number = PreferenceScope.User; - protected activeURI: string = ''; - protected activeScopeIsFolder: boolean = false; + protected readonly onEditorScrollEmitter = new Emitter(); + /** + * true = at top; false = not at top + */ + readonly onEditorDidScroll = this.onEditorScrollEmitter.event; + protected scrollContainerRef: React.RefObject = React.createRef(); protected hasRendered = false; - protected _preferenceScope: Preference.SelectedScopeDetails = Preference.DEFAULT_SCOPE; + protected shouldScroll: boolean = true; + protected lastUserSelection: string = ''; + protected isAtScrollTop: boolean = true; + protected firstVisibleChildID: string = ''; - @inject(PreferencesEventService) protected readonly preferencesEventService: PreferencesEventService; @inject(PreferenceService) protected readonly preferenceValueRetrievalService: PreferenceService; - @inject(PreferencesTreeProvider) protected readonly preferenceTreeProvider: PreferencesTreeProvider; + @inject(PreferenceTreeModel) protected readonly model: PreferenceTreeModel; @inject(SinglePreferenceDisplayFactory) protected readonly singlePreferenceFactory: SinglePreferenceDisplayFactory; @postConstruct() @@ -57,21 +63,14 @@ export class PreferencesEditorWidget extends ReactWidget { this.onRender.push(Disposable.create(() => this.hasRendered = true)); this.id = PreferencesEditorWidget.ID; this.title.label = PreferencesEditorWidget.LABEL; - this.preferenceValueRetrievalService.onPreferenceChanged((preferenceChange): void => { + this.preferenceValueRetrievalService.onPreferenceChanged((): void => { this.update(); }); - this.preferencesEventService.onDisplayChanged.event(didChangeTree => this.handleChangeDisplay(didChangeTree)); - this.preferencesEventService.onNavTreeSelection.event(e => this.scrollToEditorElement(e.nodeID)); - this.currentDisplay = this.preferenceTreeProvider.currentTree; - this.properties = this.preferenceTreeProvider.propertyList; + this.model.onFilterChanged(({ filterCleared }) => this.handleDisplayChange(filterCleared)); + this.model.onSelectionChanged(e => this.handleSelectionChange(e)); this.update(); } - set preferenceScope(preferenceScopeDetails: Preference.SelectedScopeDetails) { - this._preferenceScope = preferenceScopeDetails; - this.handleChangeScope(this._preferenceScope); - } - protected callAfterFirstRender(callback: Function): void { if (this.hasRendered) { callback(); @@ -88,46 +87,80 @@ export class PreferencesEditorWidget extends ReactWidget { } protected render(): React.ReactNode { - const visibleCategories = this.currentDisplay.children.filter(category => category.visible); + const visibleNodes = Array.from(this.model.currentRows.values()); return (
- {!!visibleCategories.length ? visibleCategories.map(category => this.renderCategory(category as Preference.Branch)) : this.renderNoResultMessage()} + {!visibleNodes.length ? this.renderNoResultMessage() : visibleNodes.map(nodeRow => { + if (!CompositeTreeNode.is(nodeRow.node)) { + return this.renderSingleEntry(nodeRow.node); + } else { + return this.renderCategoryHeader(nodeRow); + } + })}
); } - protected handleChangeDisplay = (didGenerateNewTree: boolean): void => { - if (didGenerateNewTree) { - this.currentDisplay = this.preferenceTreeProvider.currentTree; - this.properties = this.preferenceTreeProvider.propertyList; + protected handleDisplayChange = (filterWasCleared: boolean = false): void => { + const currentVisibleChild = this.firstVisibleChildID; + this.update(); + const oldVisibleNode = this.model.currentRows.get(currentVisibleChild); + // Scroll if the old visible node is visible in the new display. Otherwise go to top. + if (!filterWasCleared && oldVisibleNode && !(CompositeTreeNode.is(oldVisibleNode.node) && oldVisibleNode.visibleChildren === 0)) { + setTimeout(() => // set timeout to allow render to finish. + Array.from(this.node.getElementsByTagName('li')).find(element => element.getAttribute('data-id') === currentVisibleChild)?.scrollIntoView()); + } else { this.node.scrollTop = 0; } - this.update(); }; - protected onScroll = (): void => { + protected doOnScroll = (): void => { const scrollContainer = this.node; - const scrollIsTop = scrollContainer.scrollTop === 0; - const visibleChildren: string[] = []; - this.addFirstVisibleChildId(scrollContainer, visibleChildren); - if (visibleChildren.length) { - this.preferencesEventService.onEditorScroll.fire({ - firstVisibleChildId: visibleChildren[0], - isTop: scrollIsTop - }); + const { selectionAncestorID, expansionAncestorID } = this.findFirstVisibleChildID(scrollContainer) ?? {}; + if (selectionAncestorID !== this.lastUserSelection) { + this.shouldScroll = false; // prevents event feedback loop. + const selectionAncestor = this.model.getNode(selectionAncestorID) as SelectableTreeNode; + const expansionAncestor = this.model.getNode(expansionAncestorID) as ExpandableTreeNode; + if (expansionAncestor) { + this.model.collapseAllExcept(expansionAncestor); + } + if (selectionAncestor) { + this.model.selectNode(selectionAncestor); + } + this.shouldScroll = true; + } + if (this.isAtScrollTop && scrollContainer.scrollTop !== 0) { + this.isAtScrollTop = false; + this.onEditorScrollEmitter.fire(false); // no longer at top + } else if (!this.isAtScrollTop && scrollContainer.scrollTop === 0) { + this.isAtScrollTop = true; + this.onEditorScrollEmitter.fire(true); // now at top } + this.lastUserSelection = ''; }; - protected addFirstVisibleChildId(container: Element, array: string[]): void { - const children = container.children; - for (let i = 0; i < children.length && !array.length; i++) { - const id = children[i].getAttribute('data-id'); - if (id && this.isInView(children[i] as HTMLElement, container as HTMLElement)) { - array.push(id); - } else if (!array.length) { - this.addFirstVisibleChildId(children[i], array); + onScroll = debounce(this.doOnScroll, 10); + + protected findFirstVisibleChildID(container: Element): { selectionAncestorID: string, expansionAncestorID: string; } | undefined { + const children = container.getElementsByTagName('li'); + let selectionAncestorID: string = ''; + let expansionAncestorID: string = ''; + for (let i = 0; i < children.length; i++) { + const currentChild = children[i]; + const id = currentChild.getAttribute('data-id'); + if (id) { + if (currentChild.classList.contains(HEADER_CLASS)) { + selectionAncestorID = id; + expansionAncestorID = id; + } else if (currentChild.classList.contains(SUBHEADER_CLASS)) { + selectionAncestorID = id; + } + if (this.isInView(currentChild as HTMLElement, container as HTMLElement)) { + this.firstVisibleChildID = id; + return { selectionAncestorID, expansionAncestorID }; + } } } } @@ -145,32 +178,25 @@ export class PreferencesEditorWidget extends ReactWidget { ) }); - protected handleChangeScope = ({ scope, uri, activeScopeIsFolder }: Preference.SelectedScopeDetails): void => { - this.activeScope = Number(scope); - this.activeURI = uri; - this.activeScopeIsFolder = activeScopeIsFolder === 'true'; - this.update(); - }; + protected renderSingleEntry(node: TreeNode): React.ReactNode { + const values = this.preferenceValueRetrievalService.inspect(node.id, this.model.currentScope.uri); + const preferenceNodeWithValueInAllScopes = { ...node, preference: { data: this.model.propertyList[node.id], values } }; + return this.singlePreferenceFactory.render(preferenceNodeWithValueInAllScopes); + } - protected renderCategory(category: Preference.Branch): React.ReactNode { - const children = category.children.concat(category.leaves).sort((a, b) => this.sort(a.id, b.id)); - const isCategory = category.parent?.parent === undefined; - const categoryLevelClass = isCategory ? 'settings-section-category-title' : 'settings-section-subcategory-title'; - return category.visible && ( + protected renderCategoryHeader({ node, visibleChildren }: PreferenceTreeNodeRow): React.ReactNode { + if (visibleChildren === 0) { + return undefined; + } + const isCategory = ExpandableTreeNode.is(node); + const className = isCategory ? HEADER_CLASS : SUBHEADER_CLASS; + return node.visible && (
    -
  • {category.name}
  • - {children.map((preferenceNode: SelectableTreeNode | Preference.Branch) => { - if (Preference.Branch.is(preferenceNode)) { - return this.renderCategory(preferenceNode); - } - const values = this.preferenceValueRetrievalService.inspect(preferenceNode.id, this.activeURI); - const preferenceNodeWithValueInAllScopes = { ...preferenceNode, preference: { data: this.properties[preferenceNode.id], values } }; - return this.singlePreferenceFactory.render(preferenceNodeWithValueInAllScopes); - })} +
  • {node.name}
); } @@ -179,23 +205,17 @@ export class PreferencesEditorWidget extends ReactWidget { return
That search query has returned no results.
; } - protected scrollToEditorElement(nodeID: string): void { - if (nodeID) { - const el = document.getElementById(`${nodeID}-editor`); - if (el) { - // Timeout to allow render cycle to finish. - setTimeout(() => el.scrollIntoView()); + protected handleSelectionChange(selectionEvent: readonly Readonly[]): void { + if (this.shouldScroll) { + const nodeID = selectionEvent[0]?.id; + if (nodeID) { + this.lastUserSelection = nodeID; + const el = document.getElementById(`${nodeID}-editor`); + if (el) { + // Timeout to allow render cycle to finish. + setTimeout(() => el.scrollIntoView()); + } } } } - - /** - * Sort two strings. - * - * @param a the first string. - * @param b the second string. - */ - protected sort(a: string, b: string): number { - return a.localeCompare(b, undefined, { ignorePunctuation: true }); - } } diff --git a/packages/preferences/src/browser/views/preference-scope-tabbar-widget.tsx b/packages/preferences/src/browser/views/preference-scope-tabbar-widget.tsx index 9044bacc46929..c5abb191c3818 100644 --- a/packages/preferences/src/browser/views/preference-scope-tabbar-widget.tsx +++ b/packages/preferences/src/browser/views/preference-scope-tabbar-widget.tsx @@ -20,9 +20,9 @@ import { PreferenceScope, Message, ContextMenuRenderer, LabelProvider } from '@t import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import URI from '@theia/core/lib/common/uri'; import { FileStat } from '@theia/filesystem/lib/common/files'; -import { PreferencesEventService } from '../util/preference-event-service'; import { PreferenceScopeCommandManager, FOLDER_SCOPE_MENU_PATH } from '../util/preference-scope-command-manager'; import { Preference } from '../util/preference-types'; +import { Emitter } from '@theia/core'; const USER_TAB_LABEL = 'User'; const USER_TAB_INDEX = PreferenceScope[USER_TAB_LABEL].toString(); @@ -37,7 +37,6 @@ const LABELED_FOLDER_TAB_CLASSNAME = 'preferences-folder-tab'; const FOLDER_DROPDOWN_CLASSNAME = 'preferences-folder-dropdown'; const FOLDER_DROPDOWN_ICON_CLASSNAME = 'preferences-folder-dropdown-icon'; const TABBAR_UNDERLINE_CLASSNAME = 'tabbar-underline'; -const SHADOW_CLASSNAME = 'with-shadow'; const SINGLE_FOLDER_TAB_CLASSNAME = `${PREFERENCE_TAB_CLASSNAME} ${GENERAL_FOLDER_TAB_CLASSNAME} ${LABELED_FOLDER_TAB_CLASSNAME}`; const UNSELECTED_FOLDER_DROPDOWN_CLASSNAME = `${PREFERENCE_TAB_CLASSNAME} ${GENERAL_FOLDER_TAB_CLASSNAME} ${FOLDER_DROPDOWN_CLASSNAME}`; const SELECTED_FOLDER_DROPDOWN_CLASSNAME = `${PREFERENCE_TAB_CLASSNAME} ${GENERAL_FOLDER_TAB_CLASSNAME} ${LABELED_FOLDER_TAB_CLASSNAME} ${FOLDER_DROPDOWN_CLASSNAME}`; @@ -46,16 +45,23 @@ const SELECTED_FOLDER_DROPDOWN_CLASSNAME = `${PREFERENCE_TAB_CLASSNAME} ${GENERA export class PreferencesScopeTabBar extends TabBar { static ID = 'preferences-scope-tab-bar'; - @inject(PreferencesEventService) protected readonly preferencesEventService: PreferencesEventService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(PreferenceScopeCommandManager) protected readonly preferencesMenuFactory: PreferenceScopeCommandManager; @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + protected readonly onScopeChangedEmitter = new Emitter(); + readonly onScopeChanged = this.onScopeChangedEmitter.event; + protected folderTitle: Title; protected currentWorkspaceRoots: FileStat[] = []; protected currentSelection: Preference.SelectedScopeDetails = Preference.DEFAULT_SCOPE; protected editorScrollAtTop = true; + + get currentScope(): Preference.SelectedScopeDetails { + return this.currentSelection; + } + protected setNewScopeSelection(newSelection: Preference.SelectedScopeDetails): void { const newIndex = this.titles.findIndex(title => title.dataset.scope === newSelection.scope); @@ -82,16 +88,6 @@ export class PreferencesScopeTabBar extends TabBar { this.doUpdateDisplay(newRoots); }); this.workspaceService.onWorkspaceLocationChanged(() => this.updateWorkspaceTab()); - this.preferencesEventService.onEditorScroll.event((e: Preference.MouseScrollDetails) => { - if (e.isTop !== this.editorScrollAtTop) { - this.editorScrollAtTop = e.isTop; - if (this.editorScrollAtTop) { - this.removeClass(SHADOW_CLASSNAME); - } else { - this.addClass(SHADOW_CLASSNAME); - } - } - }); const tabUnderline = document.createElement('div'); tabUnderline.className = TABBAR_UNDERLINE_CLASSNAME; this.node.append(tabUnderline); @@ -99,7 +95,9 @@ export class PreferencesScopeTabBar extends TabBar { protected setupInitialDisplay(): void { this.addUserTab(); - this.addWorkspaceTab(); + if (this.workspaceService.workspace) { + this.addWorkspaceTab(this.workspaceService.workspace); + } this.addOrUpdateFolderTab(); } @@ -131,19 +129,19 @@ export class PreferencesScopeTabBar extends TabBar { })); } - protected addWorkspaceTab(): void { - if (!!this.workspaceService.workspace) { - this.addTab(new Title({ - dataset: this.getWorkspaceDataset(), - label: WORKSPACE_TAB_LABEL, - owner: this, - className: PREFERENCE_TAB_CLASSNAME, - })); - } + protected addWorkspaceTab(currentWorkspace: FileStat): Title { + const workspaceTabTitle = new Title({ + dataset: this.getWorkspaceDataset(currentWorkspace), + label: WORKSPACE_TAB_LABEL, + owner: this, + className: PREFERENCE_TAB_CLASSNAME, + }); + this.addTab(workspaceTabTitle); + return workspaceTabTitle; } - protected getWorkspaceDataset(): Preference.SelectedScopeDetails { - const { resource, isDirectory } = this.workspaceService.workspace!; + protected getWorkspaceDataset(currentWorkspace: FileStat): Preference.SelectedScopeDetails { + const { resource, isDirectory } = currentWorkspace; const scope = WORKSPACE_TAB_INDEX; const activeScopeIsFolder = isDirectory.toString(); return { uri: resource.toString(), activeScopeIsFolder, scope }; @@ -237,15 +235,17 @@ export class PreferencesScopeTabBar extends TabBar { } protected updateWorkspaceTab(): void { - // Will always be present - otherwise workspace cannot change. - const workspaceTitle = this.titles.find(title => title.label === WORKSPACE_TAB_LABEL)!; - workspaceTitle.dataset = this.getWorkspaceDataset(); - if (this.currentSelection.scope === PreferenceScope.Workspace.toString()) { - this.setNewScopeSelection(workspaceTitle.dataset as Preference.SelectedScopeDetails); + const currentWorkspace = this.workspaceService.workspace; + if (currentWorkspace) { + const workspaceTitle = this.titles.find(title => title.label === WORKSPACE_TAB_LABEL) ?? this.addWorkspaceTab(currentWorkspace); + workspaceTitle.dataset = this.getWorkspaceDataset(currentWorkspace); + if (this.currentSelection.scope === PreferenceScope.Workspace.toString()) { + this.setNewScopeSelection(workspaceTitle.dataset as Preference.SelectedScopeDetails); + } } } protected emitNewScope(): void { - this.preferencesEventService.onTabScopeSelected.fire(this.currentSelection); + this.onScopeChangedEmitter.fire(this.currentSelection); } } diff --git a/packages/preferences/src/browser/views/preference-searchbar-widget.tsx b/packages/preferences/src/browser/views/preference-searchbar-widget.tsx index 737a6c844cdba..1f3318b2b39f5 100644 --- a/packages/preferences/src/browser/views/preference-searchbar-widget.tsx +++ b/packages/preferences/src/browser/views/preference-searchbar-widget.tsx @@ -14,21 +14,21 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject, postConstruct } from 'inversify'; +import { injectable, postConstruct } from 'inversify'; import { ReactWidget } from '@theia/core/lib/browser'; -import { Disposable } from '@theia/core/lib/common/disposable'; import * as React from 'react'; import { debounce } from 'lodash'; -import { PreferencesEventService } from '../util/preference-event-service'; +import { Disposable, Emitter } from '@theia/core'; @injectable() export class PreferencesSearchbarWidget extends ReactWidget { static readonly ID = 'settings.header'; static readonly LABEL = 'Settings Header'; - protected searchbarRef: React.RefObject = React.createRef(); + protected readonly onFilterStringChangedEmitter = new Emitter(); + readonly onFilterChanged = this.onFilterStringChangedEmitter.event; - @inject(PreferencesEventService) protected readonly preferencesEventService: PreferencesEventService; + protected searchbarRef: React.RefObject = React.createRef(); @postConstruct() protected init(): void { @@ -43,7 +43,7 @@ export class PreferencesSearchbarWidget extends ReactWidget { }; protected search = debounce((value: string) => { - this.preferencesEventService.onSearch.fire({ query: value }); + this.onFilterStringChangedEmitter.fire(value); this.update(); }, 200); @@ -59,6 +59,7 @@ export class PreferencesSearchbarWidget extends ReactWidget {
{ - if (didChangeTree) { - this.updateDisplay(); - } - }); - this.preferencesEventService.onEditorScroll.event(e => { - this.handleEditorScroll(e.firstVisibleChildId); - }); this.id = PreferencesTreeWidget.ID; + this.toDispose.pushAll([ + this.model.onFilterChanged(() => { + this.updateRows(); + }), + ]); } - protected handleEditorScroll(firstVisibleChildId: string): void { - this.shouldFireSelectionEvents = false; - if (firstVisibleChildId !== this.firstVisibleLeafNodeID) { - const { selectionAncestor, expansionAncestor } = this.getAncestorsForVisibleNode(firstVisibleChildId); - - this.firstVisibleLeafNodeID = firstVisibleChildId; - this.model.expandNode(expansionAncestor); - this.collapseAllExcept(expansionAncestor); - if (selectionAncestor) { - this.model.selectNode(selectionAncestor); + doUpdateRows(): void { + this.rows = new Map(); + for (const [id, nodeRow] of this.model.currentRows.entries()) { + if (nodeRow.visibleChildren > 0 && (ExpandableTreeNode.is(nodeRow.node) || ExpandableTreeNode.isExpanded(nodeRow.node.parent))) { + this.rows.set(id, nodeRow); } } - this.shouldFireSelectionEvents = true; + this.updateScrollToRow(); } - protected collapseAllExcept(openNode: Preference.TreeExtension | undefined): void { - const children = (this.model.root as CompositeTreeNode).children as ExpandableTreeNode[]; - children.forEach(child => { - if (child !== openNode && child.expanded) { - this.model.collapseNode(child); - } - }); + protected doRenderNodeRow({ depth, visibleChildren, node, isExpansible }: PreferenceTreeNodeRow): React.ReactNode { + return this.renderNode(node, { depth, visibleChildren, isExpansible }); } - protected getAncestorsForVisibleNode(visibleNodeID: string): { selectionAncestor: SelectableTreeNode | undefined, expansionAncestor: ExpandableTreeNode | undefined; } { - const isNonLeafNode = visibleNodeID.endsWith('-id'); - const isSubgroupNode = isNonLeafNode && visibleNodeID.includes('.'); - let expansionAncestor: ExpandableTreeNode; - let selectionAncestor: SelectableTreeNode; - - if (isSubgroupNode) { - selectionAncestor = this.model.getNode(visibleNodeID) as SelectableTreeNode; - expansionAncestor = selectionAncestor?.parent as ExpandableTreeNode; - } else if (isNonLeafNode) { - selectionAncestor = this.model.getNode(visibleNodeID) as SelectableTreeNode; - expansionAncestor = selectionAncestor as Preference.TreeExtension as ExpandableTreeNode; - } else { - const labels = visibleNodeID.split('.'); - const hasSubgroupAncestor = labels.length > 2; - const expansionAncestorID = `${labels[0]}-id`; - expansionAncestor = this.model.getNode(expansionAncestorID) as ExpandableTreeNode; - if (hasSubgroupAncestor) { - const subgroupID = labels.slice(0, 2).join('.') + '-id'; - selectionAncestor = this.model.getNode(subgroupID) as SelectableTreeNode; - } else { - // The last selectable child that precedes the visible item alphabetically - selectionAncestor = [...(expansionAncestor?.children || [])] - .reverse().find(child => child.visible && child.id < visibleNodeID) as SelectableTreeNode || expansionAncestor; - } - } - return { selectionAncestor, expansionAncestor }; - } - - protected onAfterAttach(msg: Message): void { - this.updateDisplay(); - this.model.onSelectionChanged(previousAndCurrentSelectedNodes => this.fireEditorScrollForNewSelection(previousAndCurrentSelectedNodes)); - super.onAfterAttach(msg); - } - - protected updateDisplay(): void { - if (this.preferenceTreeProvider) { - this.model.root = this.preferenceTreeProvider.currentTree; - const nodes = Object.keys(this.preferenceTreeProvider.propertyList) - .map(propertyName => ({ [propertyName]: this.preferenceTreeProvider.propertyList[propertyName] })); - this.decorator.fireDidChangeDecorations(nodes); - // If the tree has changed but we know the visible node, scroll to it. - if (this.firstVisibleLeafNodeID) { - const { selectionAncestor } = this.getAncestorsForVisibleNode(this.firstVisibleLeafNodeID); - if (selectionAncestor?.visible) { - this.preferencesEventService.onNavTreeSelection.fire({ nodeID: this.firstVisibleLeafNodeID }); - } - } - this.update(); - } - } - - protected fireEditorScrollForNewSelection(previousAndCurrentSelectedNodes: readonly SelectableTreeNode[]): void { - if (this.shouldFireSelectionEvents) { - const [currentSelectedNode] = previousAndCurrentSelectedNodes; - this.firstVisibleLeafNodeID = currentSelectedNode.id; - this.preferencesEventService.onNavTreeSelection.fire({ nodeID: currentSelectedNode.id }); - } - } - - protected renderNode(node: TreeNode, props: NodeProps): React.ReactNode { + protected renderNode(node: TreeNode, props: PreferenceTreeNodeProps): React.ReactNode { if (!TreeNode.isVisible(node)) { return undefined; } const attributes = this.createNodeAttributes(node, props); - const printedNameWithVisibleChildren = node.name && this.preferenceTreeProvider.isFiltered - ? `${node.name} (${this.calculateVisibleLeaves(node)})` + const printedNameWithVisibleChildren = node.name && this.model.isFiltered + ? `${node.name} (${props.visibleChildren})` : node.name; const content =
@@ -167,26 +83,8 @@ export class PreferencesTreeWidget extends TreeWidget { return React.createElement('div', attributes, content); } - protected calculateVisibleLeaves(node: Preference.TreeExtension): number { - let visibleLeaves = 0; - // The check for node.name prevents recursion at the level of `root`. - if (node.children) { - node.children.forEach(child => { - visibleLeaves += this.calculateVisibleLeaves(child); - }); - } - if (node.leaves) { - node.leaves.forEach(leaf => { - if (leaf.visible) { - visibleLeaves++; - }; - }); - } - return visibleLeaves; - } - - protected renderExpansionToggle(node: Preference.TreeExtension, props: NodeProps): React.ReactNode { - if (node.children && node.children.every(child => !child.visible)) { + protected renderExpansionToggle(node: TreeNode, props: PreferenceTreeNodeProps): React.ReactNode { + if (ExpandableTreeNode.is(node) && !props.isExpansible) { return
; } return super.renderExpansionToggle(node, props); diff --git a/packages/preferences/src/browser/views/preference-widget-bindings.ts b/packages/preferences/src/browser/views/preference-widget-bindings.ts index 77ca490e23f34..78fe6b41a831c 100644 --- a/packages/preferences/src/browser/views/preference-widget-bindings.ts +++ b/packages/preferences/src/browser/views/preference-widget-bindings.ts @@ -13,8 +13,8 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { interfaces } from 'inversify'; -import { WidgetFactory, createTreeContainer, TreeWidget, TreeProps, defaultTreeProps, TreeDecoratorService } from '@theia/core/lib/browser'; +import { interfaces, Container } from 'inversify'; +import { WidgetFactory, createTreeContainer, TreeWidget, TreeProps, defaultTreeProps, TreeDecoratorService, TreeModel } from '@theia/core/lib/browser'; import { SinglePreferenceDisplayFactory } from './components/single-preference-display-factory'; import { SinglePreferenceWrapper } from './components/single-preference-wrapper'; import { PreferencesWidget } from './preference-widget'; @@ -24,58 +24,35 @@ import { PreferencesSearchbarWidget } from './preference-searchbar-widget'; import { PreferencesScopeTabBar } from './preference-scope-tabbar-widget'; import { PreferencesDecorator } from '../preferences-decorator'; import { PreferencesDecoratorService } from '../preferences-decorator-service'; +import { PreferenceTreeModel } from '../preference-tree-model'; export function bindPreferencesWidgets(bind: interfaces.Bind): void { - bind(PreferencesWidget).toSelf().inSingletonScope(); + bind(PreferencesWidget) + .toDynamicValue(({ container }) => createPreferencesWidgetContainer(container).get(PreferencesWidget)) + .inSingletonScope(); bind(WidgetFactory).toDynamicValue(({ container }) => ({ id: PreferencesWidget.ID, createWidget: () => container.get(PreferencesWidget) })).inSingletonScope(); - - bind(SinglePreferenceWrapper).toSelf(); - - bind(PreferencesTreeWidget).toDynamicValue(ctx => - createPreferencesTree(ctx.container) - ).inSingletonScope(); - bind(WidgetFactory).toDynamicValue(context => ({ - id: PreferencesTreeWidget.ID, - createWidget: (): PreferencesTreeWidget => context.container.get(PreferencesTreeWidget), - })).inSingletonScope(); - - bind(PreferencesEditorWidget).toSelf().inSingletonScope(); - bind(WidgetFactory).toDynamicValue(context => ({ - id: PreferencesEditorWidget.ID, - createWidget: (): PreferencesEditorWidget => context.container.get(PreferencesEditorWidget), - })).inSingletonScope(); - - bind(PreferencesSearchbarWidget).toSelf().inSingletonScope(); - bind(WidgetFactory).toDynamicValue(context => ({ - id: PreferencesSearchbarWidget.ID, - createWidget: (): PreferencesSearchbarWidget => context.container.get(PreferencesSearchbarWidget), - })).inSingletonScope(); - - bind(PreferencesScopeTabBar).toSelf().inSingletonScope(); - bind(WidgetFactory).toDynamicValue(context => ({ - id: PreferencesScopeTabBar.ID, - createWidget: (): PreferencesScopeTabBar => context.container.get(PreferencesScopeTabBar), - })).inSingletonScope(); - - bind(SinglePreferenceDisplayFactory).toSelf().inSingletonScope(); } -function createPreferencesTree(parent: interfaces.Container): PreferencesTreeWidget { +function createPreferencesWidgetContainer(parent: interfaces.Container): Container { const child = createTreeContainer(parent); + child.bind(PreferenceTreeModel).toSelf(); + child.rebind(TreeModel).toService(PreferenceTreeModel); child.unbind(TreeWidget); child.bind(PreferencesTreeWidget).toSelf(); child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, search: false }); - - bindPreferencesDecorator(child); - - return child.get(PreferencesTreeWidget); -} - -function bindPreferencesDecorator(parent: interfaces.Container): void { - parent.bind(PreferencesDecorator).toSelf().inSingletonScope(); - parent.bind(PreferencesDecoratorService).toSelf().inSingletonScope(); - parent.rebind(TreeDecoratorService).toService(PreferencesDecoratorService); + child.bind(PreferencesEditorWidget).toSelf(); + child.bind(PreferencesDecorator).toSelf(); + child.bind(PreferencesDecoratorService).toSelf(); + child.rebind(TreeDecoratorService).toService(PreferencesDecoratorService); + + child.bind(SinglePreferenceWrapper).toSelf(); + child.bind(PreferencesSearchbarWidget).toSelf(); + child.bind(PreferencesScopeTabBar).toSelf(); + child.bind(SinglePreferenceDisplayFactory).toSelf(); + child.bind(PreferencesWidget).toSelf(); + + return child; } diff --git a/packages/preferences/src/browser/views/preference-widget.tsx b/packages/preferences/src/browser/views/preference-widget.tsx index 5fdd117b6979e..b4f2907802e92 100644 --- a/packages/preferences/src/browser/views/preference-widget.tsx +++ b/packages/preferences/src/browser/views/preference-widget.tsx @@ -15,12 +15,14 @@ ********************************************************************************/ import { postConstruct, injectable, inject } from 'inversify'; -import { WidgetManager, Panel, Widget, Message, } from '@theia/core/lib/browser'; -import { Preference } from '../util/preference-types'; +import { Panel, Widget, Message, } from '@theia/core/lib/browser'; import { PreferencesEditorWidget } from './preference-editor-widget'; import { PreferencesTreeWidget } from './preference-tree-widget'; import { PreferencesSearchbarWidget } from './preference-searchbar-widget'; import { PreferencesScopeTabBar } from './preference-scope-tabbar-widget'; +import { Preference } from '../util/preference-types'; + +const SHADOW_CLASSNAME = 'with-shadow'; @injectable() export class PreferencesWidget extends Panel { @@ -33,21 +35,13 @@ export class PreferencesWidget extends Panel { */ static readonly LABEL = 'Preferences'; - protected _preferenceScope: Preference.SelectedScopeDetails = Preference.DEFAULT_SCOPE; - - @inject(PreferencesEditorWidget) protected editorWidget: PreferencesEditorWidget; - @inject(PreferencesTreeWidget) protected treeWidget: PreferencesTreeWidget; - @inject(PreferencesSearchbarWidget) protected searchbarWidget: PreferencesSearchbarWidget; - @inject(PreferencesScopeTabBar) protected tabBarWidget: PreferencesScopeTabBar; - @inject(WidgetManager) protected readonly manager: WidgetManager; - - get preferenceScope(): Preference.SelectedScopeDetails { - return this._preferenceScope; - } + @inject(PreferencesEditorWidget) protected readonly editorWidget: PreferencesEditorWidget; + @inject(PreferencesTreeWidget) protected readonly treeWidget: PreferencesTreeWidget; + @inject(PreferencesSearchbarWidget) protected readonly searchbarWidget: PreferencesSearchbarWidget; + @inject(PreferencesScopeTabBar) protected readonly tabBarWidget: PreferencesScopeTabBar; - set preferenceScope(preferenceScopeDetails: Preference.SelectedScopeDetails) { - this._preferenceScope = preferenceScopeDetails; - this.editorWidget.preferenceScope = this._preferenceScope; + get currentScope(): Preference.SelectedScopeDetails { + return this.tabBarWidget.currentScope; } protected onResize(msg: Widget.ResizeMessage): void { @@ -67,28 +61,31 @@ export class PreferencesWidget extends Panel { } @postConstruct() - protected async init(): Promise { + protected init(): void { this.id = PreferencesWidget.ID; this.title.label = PreferencesWidget.LABEL; this.title.closable = true; this.addClass('theia-settings-container'); this.title.iconClass = 'fa fa-sliders'; - this.searchbarWidget = await this.manager.getOrCreateWidget(PreferencesSearchbarWidget.ID); this.searchbarWidget.addClass('preferences-searchbar-widget'); this.addWidget(this.searchbarWidget); - this.tabBarWidget = await this.manager.getOrCreateWidget(PreferencesScopeTabBar.ID); this.tabBarWidget.addClass('preferences-tabbar-widget'); this.addWidget(this.tabBarWidget); - this.treeWidget = await this.manager.getOrCreateWidget(PreferencesTreeWidget.ID); this.treeWidget.addClass('preferences-tree-widget'); this.addWidget(this.treeWidget); - this.editorWidget = await this.manager.getOrCreateWidget(PreferencesEditorWidget.ID); this.editorWidget.addClass('preferences-editor-widget'); this.addWidget(this.editorWidget); + this.editorWidget.onEditorDidScroll(editorIsAtTop => { + if (editorIsAtTop) { + this.tabBarWidget.removeClass(SHADOW_CLASSNAME); + } else { + this.tabBarWidget.addClass(SHADOW_CLASSNAME); + } + }); this.update(); }