From ea5d25ad1f52419b7b9d68e11363c4318a768fc5 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 28 Apr 2021 14:58:31 -0500 Subject: [PATCH 1/2] Refactor Preference UI; Add commonly used section Signed-off-by: Colin Grant --- .../preferences/preference-provider.ts | 9 + .../browser/preferences/preference-service.ts | 6 +- packages/core/src/browser/widgets/widget.ts | 4 + .../abstract-resource-preference-provider.ts | 5 +- .../src/browser/preference-tree-model.ts | 114 +++-- .../src/browser/preferences-contribution.ts | 23 +- .../browser/preferences-decorator-service.ts | 28 -- .../src/browser/preferences-decorator.ts | 138 ------ .../preferences/src/browser/style/index.css | 65 ++- .../util/preference-scope-command-manager.ts | 4 +- .../browser/util/preference-tree-generator.ts | 213 +++++---- ...=> preference-tree-label-provider.spec.ts} | 68 ++- .../util/preference-tree-label-provider.ts | 64 +++ .../src/browser/util/preference-types.ts | 75 +-- .../src/browser/views/components/index.tsx | 22 - .../components/preference-array-input.ts | 157 +++++++ .../components/preference-array-input.tsx | 113 ----- .../components/preference-boolean-input.ts | 49 ++ .../components/preference-boolean-input.tsx | 55 --- .../views/components/preference-json-input.ts | 63 +++ .../components/preference-json-input.tsx | 34 -- .../components/preference-node-renderer.ts | 426 ++++++++++++++++++ .../components/preference-number-input.ts | 128 ++++++ .../components/preference-number-input.tsx | 101 ----- .../components/preference-select-input.ts | 55 +++ .../components/preference-select-input.tsx | 57 --- .../components/preference-string-input.ts | 59 +++ .../components/preference-string-input.tsx | 56 --- .../single-preference-display-factory.tsx | 47 -- .../components/single-preference-wrapper.tsx | 209 --------- .../browser/views/preference-editor-widget.ts | 316 +++++++++++++ .../views/preference-editor-widget.tsx | 242 ---------- .../views/preference-scope-tabbar-widget.tsx | 95 ++-- .../views/preference-searchbar-widget.tsx | 2 +- .../browser/views/preference-tree-widget.tsx | 18 +- .../views/preference-widget-bindings.ts | 49 +- .../src/browser/views/preference-widget.tsx | 9 - .../src/browser/vsx-extensions-model.ts | 2 +- 38 files changed, 1801 insertions(+), 1379 deletions(-) delete mode 100644 packages/preferences/src/browser/preferences-decorator-service.ts delete mode 100644 packages/preferences/src/browser/preferences-decorator.ts rename packages/preferences/src/browser/util/{preference-tree-generator.spec.ts => preference-tree-label-provider.spec.ts} (50%) create mode 100644 packages/preferences/src/browser/util/preference-tree-label-provider.ts delete mode 100644 packages/preferences/src/browser/views/components/index.tsx create mode 100644 packages/preferences/src/browser/views/components/preference-array-input.ts delete mode 100644 packages/preferences/src/browser/views/components/preference-array-input.tsx create mode 100644 packages/preferences/src/browser/views/components/preference-boolean-input.ts delete mode 100644 packages/preferences/src/browser/views/components/preference-boolean-input.tsx create mode 100644 packages/preferences/src/browser/views/components/preference-json-input.ts delete mode 100644 packages/preferences/src/browser/views/components/preference-json-input.tsx create mode 100644 packages/preferences/src/browser/views/components/preference-node-renderer.ts create mode 100644 packages/preferences/src/browser/views/components/preference-number-input.ts delete mode 100644 packages/preferences/src/browser/views/components/preference-number-input.tsx create mode 100644 packages/preferences/src/browser/views/components/preference-select-input.ts delete mode 100644 packages/preferences/src/browser/views/components/preference-select-input.tsx create mode 100644 packages/preferences/src/browser/views/components/preference-string-input.ts delete mode 100644 packages/preferences/src/browser/views/components/preference-string-input.tsx delete mode 100644 packages/preferences/src/browser/views/components/single-preference-display-factory.tsx delete mode 100644 packages/preferences/src/browser/views/components/single-preference-wrapper.tsx create mode 100644 packages/preferences/src/browser/views/preference-editor-widget.ts delete mode 100644 packages/preferences/src/browser/views/preference-editor-widget.tsx diff --git a/packages/core/src/browser/preferences/preference-provider.ts b/packages/core/src/browser/preferences/preference-provider.ts index 445ba73eb4f2a..17479c52251b2 100644 --- a/packages/core/src/browser/preferences/preference-provider.ts +++ b/packages/core/src/browser/preferences/preference-provider.ts @@ -231,6 +231,15 @@ export abstract class PreferenceProvider implements Disposable { return source; } + /** + * Handles deep equality with the possibility of `undefined` + */ + static deepEqual(a: JSONValue | undefined, b: JSONValue | undefined): boolean { + if (a === b) { return true; } + if (a === undefined || b === undefined) { return false; } + return JSONExt.deepEqual(a, b); + } + protected getParsedContent(jsonData: any): { [key: string]: any } { const preferences: { [key: string]: any } = {}; if (typeof jsonData !== 'object') { diff --git a/packages/core/src/browser/preferences/preference-service.ts b/packages/core/src/browser/preferences/preference-service.ts index 4df391c6fdba3..cf6411988997b 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -24,7 +24,7 @@ import { PreferenceSchemaProvider } from './preference-contribution'; import URI from '../../common/uri'; import { PreferenceScope } from './preference-scope'; import { PreferenceConfigurations } from './preference-configurations'; -import { JSONExt } from '@phosphor/coreutils/lib/json'; +import { JSONExt, JSONValue } from '@phosphor/coreutils/lib/json'; import { OverridePreferenceName, PreferenceLanguageOverrideService } from './preference-language-override-service'; export { PreferenceScope }; @@ -186,7 +186,7 @@ export interface PreferenceService extends Disposable { * * @return an object containing the value of the given preference for all scopes. */ - inspect(preferenceName: string, resourceUri?: string): PreferenceInspection | undefined; + inspect(preferenceName: string, resourceUri?: string): PreferenceInspection | undefined; /** * Returns a new preference identifier based on the given OverridePreferenceName. * @@ -238,7 +238,7 @@ export interface PreferenceService extends Disposable { /** * Return type of the {@link PreferenceService.inspect} call. */ -export interface PreferenceInspection { +export interface PreferenceInspection { /** * The preference identifier. */ diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts index 612ac2139e9b6..667276189d608 100644 --- a/packages/core/src/browser/widgets/widget.ts +++ b/packages/core/src/browser/widgets/widget.ts @@ -36,6 +36,10 @@ export const COLLAPSED_CLASS = 'theia-mod-collapsed'; export const BUSY_CLASS = 'theia-mod-busy'; export const SELECTED_CLASS = 'theia-mod-selected'; export const FOCUS_CLASS = 'theia-mod-focus'; +export const DEFAULT_SCROLL_OPTIONS: PerfectScrollbar.Options = { + suppressScrollX: true, + minScrollbarLength: 35, +}; @injectable() export class BaseWidget extends Widget { diff --git a/packages/preferences/src/browser/abstract-resource-preference-provider.ts b/packages/preferences/src/browser/abstract-resource-preference-provider.ts index 6bd5ea780eb8a..6b85d4c45590b 100644 --- a/packages/preferences/src/browser/abstract-resource-preference-provider.ts +++ b/packages/preferences/src/browser/abstract-resource-preference-provider.ts @@ -18,7 +18,6 @@ /* eslint-disable no-null/no-null */ import * as jsoncparser from 'jsonc-parser'; -import { JSONExt } from '@theia/core/shared/@phosphor/coreutils'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { MessageService } from '@theia/core/lib/common/message-service'; import { Disposable } from '@theia/core/lib/common/disposable'; @@ -228,9 +227,7 @@ export abstract class AbstractResourcePreferenceProvider extends PreferenceProvi continue; } } - if (newValue === undefined && oldValue !== newValue - || oldValue === undefined && newValue !== oldValue // JSONExt.deepEqual() does not support handling `undefined` - || !JSONExt.deepEqual(oldValue, newValue)) { + if (!PreferenceProvider.deepEqual(newValue, oldValue)) { prefChanges.push({ preferenceName: prefName, newValue, oldValue, scope: this.getScope(), domain: this.getDomain() }); diff --git a/packages/preferences/src/browser/preference-tree-model.ts b/packages/preferences/src/browser/preference-tree-model.ts index 5e5a8e6b0df72..d6cfcfda991c9 100644 --- a/packages/preferences/src/browser/preference-tree-model.ts +++ b/packages/preferences/src/browser/preference-tree-model.ts @@ -24,25 +24,34 @@ import { PreferenceSchemaProvider, PreferenceDataProperty, NodeProps, - ExpandableTreeNode + ExpandableTreeNode, + SelectableTreeNode, } 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 { PreferenceTreeGenerator, COMMONLY_USED_SECTION_PREFIX } from './util/preference-tree-generator'; import * as fuzzy from '@theia/core/shared/fuzzy'; import { PreferencesScopeTabBar } from './views/preference-scope-tabbar-widget'; import { Preference } from './util/preference-types'; -import { Event } from '@theia/core/src/common'; +import { Event } from '@theia/core/lib/common'; -export interface PreferenceTreeNodeRow extends TreeWidget.NodeRow { - visibleChildren: number; - isExpansible?: boolean; -} export interface PreferenceTreeNodeProps extends NodeProps { visibleChildren: number; isExpansible?: boolean; } +export interface PreferenceTreeNodeRow extends Readonly, PreferenceTreeNodeProps { + node: Preference.TreeNode; +} +export enum PreferenceFilterChangeSource { + Schema, + Search, + Scope, +} +export interface PreferenceFilterChangeEvent { + source: PreferenceFilterChangeSource +} + @injectable() export class PreferenceTreeModel extends TreeModelImpl { @@ -51,7 +60,7 @@ export class PreferenceTreeModel extends TreeModelImpl { @inject(PreferenceTreeGenerator) protected readonly treeGenerator: PreferenceTreeGenerator; @inject(PreferencesScopeTabBar) protected readonly scopeTracker: PreferencesScopeTabBar; - protected readonly onTreeFilterChangedEmitter = new Emitter<{ filterCleared: boolean; rows: Map; }>(); + protected readonly onTreeFilterChangedEmitter = new Emitter(); readonly onFilterChanged = this.onTreeFilterChangedEmitter.event; protected lastSearchedFuzzy: string = ''; @@ -91,18 +100,20 @@ export class PreferenceTreeModel extends TreeModelImpl { this.toDispose.pushAll([ this.treeGenerator.onSchemaChanged(newTree => { this.root = newTree; - this.updateFilteredRows(); + this.updateFilteredRows(PreferenceFilterChangeSource.Schema); }), this.scopeTracker.onScopeChanged(scopeDetails => { - this._currentScope = Number(scopeDetails.scope); - this.updateFilteredRows(); + this._currentScope = scopeDetails.scope; + this.updateFilteredRows(PreferenceFilterChangeSource.Scope); }), 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.updateFilteredRows(PreferenceFilterChangeSource.Search); + if (this.isFiltered) { + this.expandAll(); + } }), this.onFilterChanged(() => { this.filterInput.updateResultsCount(this._totalVisibleLeaves); @@ -123,10 +134,10 @@ export class PreferenceTreeModel extends TreeModelImpl { pruneCollapsed: false, pruneSiblings: true })) { - if (TreeNode.isVisible(node)) { - if (CompositeTreeNode.is(node) || this.passesCurrentFilters(node.id)) { + if (TreeNode.isVisible(node) && Preference.TreeNode.is(node)) { + const { id } = Preference.TreeNode.getGroupAndIdFromNodeId(node.id); + if (CompositeTreeNode.is(node) || this.passesCurrentFilters(node, id)) { const depth = this.getDepthForNode(depths, node); - this.updateVisibleChildren(node); this._currentRows.set(node.id, { @@ -141,21 +152,27 @@ export class PreferenceTreeModel extends TreeModelImpl { } } - protected updateFilteredRows(filterWasCleared: boolean = false): void { + protected updateFilteredRows(source: PreferenceFilterChangeSource): void { this.updateRows(); - this.onTreeFilterChangedEmitter.fire({ filterCleared: filterWasCleared, rows: this._currentRows }); + this.onTreeFilterChangedEmitter.fire({ source }); } - 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 passesCurrentFilters(node: Preference.LeafNode, prefID: string): boolean { + if (!this.schemaProvider.isValidInScope(prefID, this._currentScope)) { + return false; + } + if (!this._isFiltered) { + return true; + } + // When filtering, VSCode will render an item that is present in the commonly used section only once but render both its possible parents in the left-hand tree. + // E.g. searching for editor.renderWhitespace will show one item in the main panel, but both 'Commonly Used' and 'Text Editor' in the left tree. + // That seems counterintuitive and introduces a number of special cases, so I prefer to remove the commonly used section entirely when the user searches. + if (node.id.startsWith(COMMONLY_USED_SECTION_PREFIX)) { + return false; + } + return fuzzy.test(this.lastSearchedFuzzy, prefID) // search matches preference name. + // search matches description. Fuzzy isn't ideal here because the score depends on the order of discovery. + || (node.preference.data.description ?? '').includes(this.lastSearchedLiteral); } protected getDepthForNode(depths: Map, node: TreeNode): number { @@ -183,13 +200,38 @@ export class PreferenceTreeModel extends TreeModelImpl { } } - 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); - } - }); + collapseAllExcept(openNode: TreeNode | undefined): void { + if (ExpandableTreeNode.is(openNode)) { + this.expandNode(openNode); + } + if (CompositeTreeNode.is(this.root)) { + this.root.children.forEach(child => { + if (child !== openNode && ExpandableTreeNode.is(child)) { + this.collapseNode(child); + } + }); + } + } + + protected expandAll(): void { + if (CompositeTreeNode.is(this.root)) { + this.root.children.forEach(child => { + if (ExpandableTreeNode.is(child)) { + this.expandNode(child); + } + }); + } + } + + /** + * @returns true if selection changed, false otherwise + */ + selectIfNotSelected(node: SelectableTreeNode): boolean { + const currentlySelected = this.selectedNodes[0]; + if (node !== currentlySelected) { + this.selectNode(node); + return true; + } + return false; } } diff --git a/packages/preferences/src/browser/preferences-contribution.ts b/packages/preferences/src/browser/preferences-contribution.ts index 91ae0b6b96890..c13dcb090b71c 100644 --- a/packages/preferences/src/browser/preferences-contribution.ts +++ b/packages/preferences/src/browser/preferences-contribution.ts @@ -64,8 +64,8 @@ export class PreferencesContribution extends AbstractViewContribution true, isVisible: w => this.withWidget(w, () => true), - execute: (preferenceNode: Preference.NodeWithValueInAllScopes) => { - this.openPreferencesJSON(preferenceNode); + execute: (preferenceId: string) => { + this.openPreferencesJSON(preferenceId); } }); commands.registerCommand(PreferencesCommands.COPY_JSON_NAME, { @@ -136,15 +136,14 @@ export class PreferencesContribution extends AbstractViewContribution { - const wasOpenedFromEditor = preferenceNode.constructor !== PreferencesWidget; + protected async openPreferencesJSON(opener: string | PreferencesWidget): Promise { 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 && preferenceNode.preference.values) { - const currentPreferenceValue = preferenceNode.preference.values; - const valueInCurrentScope = Preference.getValueInScope(currentPreferenceValue, scopeID) ?? currentPreferenceValue.defaultValue; + let preferenceId = ''; + if (typeof opener === 'string') { + preferenceId = opener; + const currentPreferenceValue = this.preferenceService.inspect(preferenceId, uri); + const valueInCurrentScope = Preference.getValueInScope(currentPreferenceValue, scopeID) ?? currentPreferenceValue?.defaultValue; this.preferenceService.set(preferenceId, valueInCurrentScope, scopeID, uri); } @@ -153,7 +152,7 @@ export class PreferencesContribution extends AbstractViewContribution { + private async obtainConfigUri(serializedScope: number, activeScopeIsFolder: boolean, resource?: string): Promise { let scope: PreferenceScope = serializedScope; - if (activeScopeIsFolder === 'true') { + if (activeScopeIsFolder) { scope = PreferenceScope.Folder; } const resourceUri = !!resource ? resource : undefined; diff --git a/packages/preferences/src/browser/preferences-decorator-service.ts b/packages/preferences/src/browser/preferences-decorator-service.ts deleted file mode 100644 index 058228523db5a..0000000000000 --- a/packages/preferences/src/browser/preferences-decorator-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2018 Red Hat, Inc. 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 } from '@theia/core/shared/inversify'; -import { AbstractTreeDecoratorService } from '@theia/core/lib/browser/tree/tree-decorator'; -import { PreferencesDecorator } from './preferences-decorator'; - -@injectable() -export class PreferencesDecoratorService extends AbstractTreeDecoratorService { - - constructor(@inject(PreferencesDecorator) protected readonly preferencesTreeDecorator: PreferencesDecorator) { - super([preferencesTreeDecorator]); - } - -} diff --git a/packages/preferences/src/browser/preferences-decorator.ts b/packages/preferences/src/browser/preferences-decorator.ts deleted file mode 100644 index 313a4d4d633cc..0000000000000 --- a/packages/preferences/src/browser/preferences-decorator.ts +++ /dev/null @@ -1,138 +0,0 @@ -/******************************************************************************** - * Copyright (C) 2018 Red Hat, Inc. 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 '@theia/core/shared/inversify'; -import { Tree, TreeDecorator, TreeDecoration, PreferenceDataProperty, PreferenceService } from '@theia/core/lib/browser'; -import { Emitter, Event, MaybePromise } from '@theia/core'; -import { escapeInvisibleChars } from '@theia/core/lib/common/strings'; - -@injectable() -export class PreferencesDecorator implements TreeDecorator { - readonly id: string = 'theia-preferences-decorator'; - - protected activeFolderUri: string | undefined; - protected preferences: { [id: string]: PreferenceDataProperty }[]; - protected preferencesDecorations: Map = new Map(); - protected readonly emitter: Emitter<(tree: Tree) => Map> = new Emitter(); - - @inject(PreferenceService) protected readonly preferencesService: PreferenceService; - - @postConstruct() - protected init(): void { - this.preferencesService.onPreferenceChanged(() => { - this.fireDidChangeDecorations(this.preferences); - }); - } - - get onDidChangeDecorations(): Event<(tree: Tree) => Map> { - return this.emitter.event; - } - - fireDidChangeDecorations(preferences: { [id: string]: PreferenceDataProperty }[]): void { - if (!this.preferences) { - this.preferences = preferences; - } - if (preferences) { - this.preferencesDecorations = new Map(preferences.map(m => { - const preferenceName = Object.keys(m)[0]; - const preferenceValue = m[preferenceName]; - const storedValue = this.preferencesService.get(preferenceName, undefined, this.activeFolderUri); - const description = this.getDescription(preferenceValue); - return [preferenceName, { - tooltip: this.buildTooltip(preferenceValue), - captionSuffixes: [ - { - data: `: ${this.getPreferenceDisplayValue(storedValue, preferenceValue.defaultValue)}` - }, - { - data: ' ' + description, - fontData: { color: 'var(--theia-descriptionForeground)' } - }] - }] as [string, TreeDecoration.Data]; - })); - } - this.emitter.fire(() => this.preferencesDecorations); - } - - decorations(tree: Tree): MaybePromise> { - return this.preferencesDecorations; - } - - protected setActiveFolder(folder: string): void { - this.activeFolderUri = folder; - this.fireDidChangeDecorations(this.preferences); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected getPreferenceDisplayValue(storedValue: any, defaultValue: any): any { - if (storedValue !== undefined) { - if (typeof storedValue === 'string') { - return escapeInvisibleChars(storedValue); - } - return storedValue; - } - return defaultValue; - } - - protected buildTooltip(data: PreferenceDataProperty): string { - let tooltips: string = ''; - if (data.description) { - tooltips = data.description; - } - if (data.defaultValue) { - tooltips += `\nDefault: ${JSON.stringify(data.defaultValue)}`; - } else if (data.default !== undefined) { - tooltips += `\nDefault: ${JSON.stringify(data.default)}`; - } - if (data.minimum) { - tooltips += `\nMin: ${data.minimum}`; - } - if (data.enum) { - tooltips += `\nAccepted Values: ${data.enum.join(', ')}`; - } - return tooltips; - } - - /** - * Get the description for the preference for display purposes. - * @param value {PreferenceDataProperty} the preference data property. - * @returns the description if available. - */ - protected getDescription(value: PreferenceDataProperty): string { - - /** - * Format the string for consistency and display purposes. - * Formatting includes: - * - capitalizing the string. - * - ensuring it ends in punctuation (`.`). - * @param str {string} the string to format. - * @returns the formatted string. - */ - function format(str: string): string { - if (str.endsWith('.')) { - return str.charAt(0).toUpperCase() + str.slice(1); - } - return `${str.charAt(0).toUpperCase() + str.slice(1)}.`; - } - - if (value.description) { - return format(value.description); - } else if (value.markdownDescription) { - return format(value.markdownDescription); - } - return ''; - } -} diff --git a/packages/preferences/src/browser/style/index.css b/packages/preferences/src/browser/style/index.css index d07167b1fdf81..cf6e7523dfa80 100644 --- a/packages/preferences/src/browser/style/index.css +++ b/packages/preferences/src/browser/style/index.css @@ -43,6 +43,14 @@ grid-template-rows: 45px 45px 1fr; } +.theia-settings-container .settings-main:not(.no-results) .settings-no-results-announcement { + display: none; +} + +.theia-settings-container .settings-main .hidden { + display: none; +} + .theia-settings-container .settings-no-results-announcement { font-weight: bold; font-size: var(--theia-ui-font-size3); @@ -76,7 +84,7 @@ #theia-main-content-panel .theia-settings-container .tabbar-underline { width: 100%; - position:absolute; + position: absolute; top: calc(var(--theia-private-horizontal-tab-height) + var(--theia-private-horizontal-tab-scrollbar-rail-height) / 2 - 1px); border-top: 1px solid var(--theia-tab-unfocusedInactiveForeground); z-index: -1; @@ -114,7 +122,6 @@ .theia-settings-container .preferences-editor-widget { grid-area: editor; - padding: 0 24px; } .theia-settings-container .preferences-editor-widget.full-pane { @@ -146,7 +153,9 @@ .theia-settings-container .settings-main-scroll-container { position: relative; + box-sizing: border-box; width: 100%; + padding: 0 24px; flex: 1 1 auto; } @@ -193,13 +202,13 @@ font-weight: bold; font-size: var(--theia-ui-font-size3); padding-left: calc(2 * var(--theia-ui-padding)); - } - +} + .theia-settings-container .settings-section-subcategory-title { font-weight: bold; font-size: var(--theia-ui-font-size2); padding-left: calc(2 * var(--theia-ui-padding)); - } +} .theia-settings-container .settings-section>li { list-style-type: none; @@ -234,12 +243,15 @@ border-right: 2px hidden; } -.theia-settings-container li.single-pref .pref-context-gutter .settings-context-menu-btn { +.theia-settings-container .settings-context-menu-btn { opacity: 0; transition: opacity .5s; } -.theia-settings-container li.single-pref .pref-context-gutter .settings-context-menu-btn.show-cog { +.theia-settings-container .single-pref:focus-within .pref-context-gutter .settings-context-menu-btn, +.theia-settings-container .pref-name:hover+.pref-context-gutter .settings-context-menu-btn, +.theia-settings-container .pref-context-gutter:hover .settings-context-menu-btn, +.theia-settings-container .pref-context-gutter.show-cog .settings-context-menu-btn { opacity: 1; } @@ -274,6 +286,10 @@ font-weight: bold; } +.theia-settings-container .preference-leaf-headline-prefix { + color: var(--theia-descriptionForeground); +} + .preferences-tree-spacer { padding-left: calc(var(--theia-ui-padding)/2); padding-right: calc(var(--theia-ui-padding)/2); @@ -299,11 +315,15 @@ border: 1px solid var(--theia-dropdown-border); } -.theia-settings-container .theia-input[type="checkbox"]:focus, +.theia-settings-container .theia-input[type="checkbox"]:focus, .theia-settings-container .theia-input[type="number"]:focus { outline-width: 2px; } +.theia-settings-container .theia-input[type="checkbox"] { + margin-left: 0; +} + /* Remove the spinners from input[type = number] on Firefox. */ .theia-settings-container .theia-input[type="number"] { -webkit-appearance: textfield; @@ -313,8 +333,8 @@ /* Remove the webkit spinners from input[type = number] on all browsers except Firefox. */ .theia-settings-container input::-webkit-outer-spin-button, .theia-settings-container input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; + -webkit-appearance: none; + margin: 0; } .theia-settings-container .pref-content-container .pref-input .pref-input-container .pref-error-notification { @@ -333,6 +353,7 @@ .theia-settings-container .pref-content-container a.theia-json-input { text-decoration: underline; + color: var(--theia-titleBar-activeForeground) } .theia-settings-container .pref-content-container a.theia-json-input:hover { @@ -351,7 +372,7 @@ } .theia-settings-container .pref-content-container .pref-input>select, -.theia-settings-container .pref-content-container .pref-input>input { +.theia-settings-container .pref-content-container .pref-input>input:not([type="checkbox"]) { width: 100%; } @@ -361,7 +382,7 @@ */ .theia-settings-container .pref-content-container.boolean { display: grid; - grid-template-columns: 30px 1fr; + grid-template-columns: 20px 1fr; } .theia-settings-container .pref-content-container.boolean .pref-description { @@ -379,6 +400,24 @@ margin-bottom: 20px; } -.theia-settings-container .settings-scope-underlined { +.theia-settings-container .preference-leaf-headline-suffix { + font-weight: normal; + color: var(--theia-descriptionForeground); +} + +.theia-settings-container .preference-leaf-headline-suffix::before { + content: ' ('; +} + +.theia-settings-container .preference-leaf-headline-suffix::after { + content: ')'; +} + +.theia-settings-container .preference-scope-underlined { text-decoration: underline; + cursor: pointer; +} + +.theia-settings-container .preference-modified-scope-wrapper:not(:last-child)::after { + content: ', '; } diff --git a/packages/preferences/src/browser/util/preference-scope-command-manager.ts b/packages/preferences/src/browser/util/preference-scope-command-manager.ts index 9e4216d07ac83..49422d92ac8f5 100644 --- a/packages/preferences/src/browser/util/preference-scope-command-manager.ts +++ b/packages/preferences/src/browser/util/preference-scope-command-manager.ts @@ -32,7 +32,7 @@ export class PreferenceScopeCommandManager { createFolderWorkspacesMenu( folderWorkspaces: FileStat[], - currentFolderURI: string, + currentFolderURI?: string, ): void { this.foldersAsCommands.forEach(folderCommand => { this.menuModelRegistry.unregisterMenuAction(folderCommand, FOLDER_SCOPE_MENU_PATH); @@ -56,7 +56,7 @@ export class PreferenceScopeCommandManager { isVisible: (callback, check) => check === 'from-tabbar', isEnabled: (callback, check) => check === 'from-tabbar', execute: (callback: (scopeDetails: Preference.SelectedScopeDetails) => void) => { - callback({ scope: PreferenceScope.Folder.toString(), uri: folderWorkspace.resource.toString(), activeScopeIsFolder: 'true' }); + callback({ scope: PreferenceScope.Folder, uri: folderWorkspace.resource.toString(), activeScopeIsFolder: true }); } }); diff --git a/packages/preferences/src/browser/util/preference-tree-generator.ts b/packages/preferences/src/browser/util/preference-tree-generator.ts index d62cf6dca42aa..edb91a2c21ff6 100644 --- a/packages/preferences/src/browser/util/preference-tree-generator.ts +++ b/packages/preferences/src/browser/util/preference-tree-generator.ts @@ -15,11 +15,13 @@ ********************************************************************************/ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; -import { CompositeTreeNode, PreferenceSchemaProvider, SelectableTreeNode } from '@theia/core/lib/browser'; +import { CompositeTreeNode, PreferenceSchemaProvider, OVERRIDE_PROPERTY_PATTERN, PreferenceDataProperty } from '@theia/core/lib/browser'; import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; import { Emitter } from '@theia/core'; import debounce = require('@theia/core/shared/lodash.debounce'); +import { Preference } from './preference-types'; +export const COMMONLY_USED_SECTION_PREFIX = 'commonly-used'; @injectable() export class PreferenceTreeGenerator { @@ -28,6 +30,40 @@ export class PreferenceTreeGenerator { protected readonly onSchemaChangedEmitter = new Emitter(); readonly onSchemaChanged = this.onSchemaChangedEmitter.event; + protected readonly commonlyUsedPreferences = [ + 'editor.autoSave', 'editor.autoSaveDelay', 'editor.fontSize', + 'editor.fontFamily', 'editor.tabSize', 'editor.renderWhitespace', + 'editor.cursorStyle', 'editor.multiCursorModifier', 'editor.insertSpaces', + 'editor.wordWrap', 'files.exclude', 'files.associations' + ]; + protected readonly topLevelCategories = new Map([ + [COMMONLY_USED_SECTION_PREFIX, 'Commonly Used'], + ['editor', 'Text Editor'], + ['workbench', 'Workbench'], + ['window', 'Window'], + ['features', 'Features'], + ['application', 'Application'], + ['extensions', 'Extensions'] + ]); + protected readonly sectionAssignments = new Map([ + ['comments', 'features'], + ['debug', 'features'], + ['diffEditor', 'editor'], + ['explorer', 'features'], + ['extensions', 'features'], + ['files', 'editor'], + ['hosted-plugin', 'features'], + ['keyboard', 'application'], + ['output', 'features'], + ['problems', 'features'], + ['preview', 'features'], + ['search', 'features'], + ['task', 'features'], + ['terminal', 'features'], + ['webview', 'features'], + ['workspace', 'application'], + ]); + protected readonly defaultTopLevelCategory = 'extensions'; @postConstruct() protected async init(): Promise { @@ -36,120 +72,131 @@ export class PreferenceTreeGenerator { this.handleChangedSchema(); } - generateTree = (): CompositeTreeNode => { + generateTree(): CompositeTreeNode { const preferencesSchema = this.schemaProvider.getCombinedSchema(); - const propertyNames = Object.keys(preferencesSchema.properties).sort((a, b) => a.localeCompare(b)); - 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'); - - const root = this.createRootNode(preferencesGroups); + const propertyNames = Object.keys(preferencesSchema.properties); + const groups = new Map(); + const root = this.createRootNode(); + for (const id of this.topLevelCategories.keys()) { + this.getOrCreatePreferencesGroup(id, id, root, groups); + } + const commonlyUsed = this.getOrCreatePreferencesGroup(COMMONLY_USED_SECTION_PREFIX, COMMONLY_USED_SECTION_PREFIX, root, groups); + for (const preference of this.commonlyUsedPreferences) { + if (preference in preferencesSchema.properties) { + this.createLeafNode(preference, commonlyUsed, preferencesSchema.properties[preference]); + } + } for (const propertyName of propertyNames) { - if (!this.preferenceConfigs.isSectionName(propertyName) && !overridePropertyIdentifier.test(propertyName)) { + if (!this.preferenceConfigs.isSectionName(propertyName) && !OVERRIDE_PROPERTY_PATTERN.test(propertyName)) { const labels = propertyName.split('.'); - const group = labels[0]; - const subgroup = labels.length > 2 && labels.slice(0, 2).join('.'); - if (!groups.has(group)) { - const parentPreferencesGroup = this.createPreferencesGroup(group, root); - groups.set(group, parentPreferencesGroup); - preferencesGroups.push(parentPreferencesGroup); - } - if (subgroup && !groups.has(subgroup)) { - 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 CompositeTreeNode; - const leafNode = this.createLeafNode(propertyName, parent); - CompositeTreeNode.addChild(parent, leafNode); + const groupID = this.getGroupName(labels); + const subgroupName = this.getSubgroupName(labels, groupID); + const subgroupID = [groupID, subgroupName].join('.'); + const toplevelParent = this.getOrCreatePreferencesGroup(groupID, groupID, root, groups); + const immediateParent = subgroupName && this.getOrCreatePreferencesGroup(subgroupID, groupID, toplevelParent, groups); + this.createLeafNode(propertyName, immediateParent || toplevelParent, preferencesSchema.properties[propertyName]); + } + } + + for (const group of groups.values()) { + if (group.id !== `${COMMONLY_USED_SECTION_PREFIX}@${COMMONLY_USED_SECTION_PREFIX}`) { + (group.children as Preference.TreeNode[]).sort((a, b) => { + const aIsComposite = CompositeTreeNode.is(a); + const bIsComposite = CompositeTreeNode.is(b); + if (aIsComposite && !bIsComposite) { + return 1; + } + if (bIsComposite && !aIsComposite) { + return -1; + } + return a.id.localeCompare(b.id); + }); } } return root; }; + protected getGroupName(labels: string[]): string { + const defaultGroup = labels[0]; + if (this.topLevelCategories.has(defaultGroup)) { + return defaultGroup; + } + const assignedGroup = this.sectionAssignments.get(defaultGroup); + if (assignedGroup) { + return assignedGroup; + } + return this.defaultTopLevelCategory; + } + + protected getSubgroupName(labels: string[], computedGroupName: string): string | undefined { + if (computedGroupName !== labels[0]) { + return labels[0]; + } else if (labels.length > 2) { + return labels[1]; + } + } + doHandleChangedSchema(): void { - this.onSchemaChangedEmitter.fire(this.generateTree()); + const newTree = this.generateTree(); + this.onSchemaChangedEmitter.fire(newTree); } handleChangedSchema = debounce(this.doHandleChangedSchema, 200); - protected createRootNode = (preferencesGroups: CompositeTreeNode[]): CompositeTreeNode => ({ - id: 'root-node-id', - name: '', - parent: undefined, - visible: true, - children: preferencesGroups - }); - - protected createLeafNode = (property: string, preferencesGroup: CompositeTreeNode): SelectableTreeNode => { - const rawLeaf = property.split('.').pop(); - const name = this.formatString(rawLeaf!); + protected createRootNode(): CompositeTreeNode { return { - id: property, - name, + id: 'root-node-id', + name: '', + parent: undefined, + visible: true, + children: [] + }; + } + + protected createLeafNode(property: string, preferencesGroup: Preference.CompositeTreeNode, data: PreferenceDataProperty): Preference.LeafNode { + const { group } = Preference.TreeNode.getGroupAndIdFromNodeId(preferencesGroup.id); + const newNode = { + id: `${group}@${property}`, + preferenceId: property, parent: preferencesGroup, visible: true, - selected: false, + preference: { data }, + depth: Preference.TreeNode.isTopLevel(preferencesGroup) ? 1 : 2, }; - }; + CompositeTreeNode.addChild(preferencesGroup, newNode); + return newNode; + } - protected createPreferencesGroup = (group: string, root: CompositeTreeNode): CompositeTreeNode => { - const isSubgroup = 'expanded' in root; - const [groupname, subgroupname] = group.split('.'); - const label = isSubgroup ? subgroupname : groupname; + protected createPreferencesGroup(id: string, group: string, root: CompositeTreeNode): Preference.CompositeTreeNode { const newNode = { - id: group, - name: this.formatString(label), + id: `${group}@${id}`, visible: true, parent: root, children: [], expanded: false, selected: false, + depth: 0, }; - if (isSubgroup) { + const isTopLevel = Preference.TreeNode.isTopLevel(newNode); + if (!isTopLevel) { delete newNode.expanded; } + newNode.depth = isTopLevel ? 0 : 1; + CompositeTreeNode.addChild(root, newNode); return newNode; - }; - - protected toTitleCase(nonTitle: string): string { - // Any non-word character or the 0-length space between a non-upper-case character and an upper-case character - return this.split(nonTitle).map(word => this.capitalizeFirst(word)).join(' ').trim(); } - protected capitalizeFirst(maybeLowerCase: string): string { - return maybeLowerCase.slice(0, 1).toLocaleUpperCase() + maybeLowerCase.slice(1); + getCustomLabelFor(id: string): string | undefined { + return this.topLevelCategories.get(id); } - /** - * Split based on any non-word character or the 0-length space between a non-upper-case character and an upper-case character - */ - private split(string: string): string[] { - const split: string[] = []; - const regex = /[A-Z]+[a-z0-9]*|[A-Z]*[a-z0-9]+/g; - // eslint-disable-next-line no-null/no-null - let match; while ((match = regex.exec(string)) !== null) { - split.push(match[0]); - } - return split; - } - - protected formatString(string: string): string { - let formatedString = string[0].toLocaleUpperCase(); - for (let i = 1; i < string.length; i++) { - if (this.isUpperCase(string[i]) && !/\s/.test(string[i - 1]) && !this.isUpperCase(string[i - 1])) { - formatedString += ' '; - } - formatedString += string[i]; - } - return formatedString.trim(); - } - - protected isUpperCase(char: string): boolean { - return char === char.toLocaleUpperCase() && char.toLocaleLowerCase() !== char.toLocaleUpperCase(); - } + protected getOrCreatePreferencesGroup(id: string, group: string, root: CompositeTreeNode, groups: Map): Preference.CompositeTreeNode { + const existingGroup = groups.get(id); + if (existingGroup) { return existingGroup; } + const newNode = this.createPreferencesGroup(id, group, root); + groups.set(id, newNode); + return newNode; + }; } diff --git a/packages/preferences/src/browser/util/preference-tree-generator.spec.ts b/packages/preferences/src/browser/util/preference-tree-label-provider.spec.ts similarity index 50% rename from packages/preferences/src/browser/util/preference-tree-generator.spec.ts rename to packages/preferences/src/browser/util/preference-tree-label-provider.spec.ts index 063ec1311f58f..a08bdf167c07a 100644 --- a/packages/preferences/src/browser/util/preference-tree-generator.spec.ts +++ b/packages/preferences/src/browser/util/preference-tree-label-provider.spec.ts @@ -28,65 +28,58 @@ FrontendApplicationConfigProvider.set({ import { expect } from 'chai'; import { Container } from '@theia/core/shared/inversify'; import { PreferenceTreeGenerator } from './preference-tree-generator'; -import { CompositeTreeNode, PreferenceSchemaProvider } from '@theia/core/lib/browser'; -import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; +import { PreferenceTreeLabelProvider } from './preference-tree-label-provider'; +import { Preference } from './preference-types'; +import { SelectableTreeNode } from '@theia/core/src/browser'; disableJSDOM(); -describe('preference-tree-generator', () => { +describe('preference-tree-label-provider', () => { - let preferenceTreeGenerator: PreferenceTreeGenerator; + let preferenceTreeLabelprovider: PreferenceTreeLabelProvider; beforeEach(() => { const container = new Container(); - container.bind(PreferenceSchemaProvider).toConstantValue({ onDidPreferenceSchemaChanged: () => { } }); - container.bind(PreferenceConfigurations).toConstantValue(undefined); - preferenceTreeGenerator = container.resolve(PreferenceTreeGenerator); + container.bind(PreferenceTreeGenerator).toConstantValue({ getCustomLabelFor: () => { } }); + preferenceTreeLabelprovider = container.resolve(PreferenceTreeLabelProvider); }); - it('PreferenceTreeGenerator.split', () => { - // We want to ensure that our `split` function emulates the following regex properly: - const splitter = /[\W_]|(?<=[^A-Z])(?=[A-Z])/; - const testString = 'aaaBbb.Ccc d E fff GGGgg_iiiJ0a'; - expect(preferenceTreeGenerator['split'](testString)).deep.eq(testString.split(splitter)); - }); - - it('PreferenceTreeGenerator.format', () => { + it('PreferenceTreeLabelProvider.format', () => { const testString = 'aaaBbbCcc Dddd eee'; - expect(preferenceTreeGenerator['formatString'](testString)).eq('Aaa Bbb Ccc Dddd eee'); + expect(preferenceTreeLabelprovider['formatString'](testString)).eq('Aaa Bbb Ccc Dddd eee'); }); - it('PreferenceTreeGenerator.format.chinese', () => { + it('PreferenceTreeLabelProvider.format.Chinese', () => { const testString = '某個設定/某个设定'; - expect(preferenceTreeGenerator['formatString'](testString)).eq('某個設定/某个设定'); + expect(preferenceTreeLabelprovider['formatString'](testString)).eq('某個設定/某个设定'); }); - it('PreferenceTreeGenerator.format.Danish', () => { + it('PreferenceTreeLabelProvider.format.Danish', () => { const testString = 'indstillingPåEnØ'; - expect(preferenceTreeGenerator['formatString'](testString)).eq('Indstilling På En Ø'); + expect(preferenceTreeLabelprovider['formatString'](testString)).eq('Indstilling På En Ø'); }); - it('PreferenceTreeGenerator.format.Greek', () => { + it('PreferenceTreeLabelProvider.format.Greek', () => { const testString = 'κάποιαΡύθμιση'; - expect(preferenceTreeGenerator['formatString'](testString)).eq('Κάποια Ρύθμιση'); + expect(preferenceTreeLabelprovider['formatString'](testString)).eq('Κάποια Ρύθμιση'); }); - it('PreferenceTreeGenerator.format.Russian', () => { + it('PreferenceTreeLabelProvider.format.Russian', () => { const testString = 'некоторыеНастройки'; - expect(preferenceTreeGenerator['formatString'](testString)).eq('Некоторые Настройки'); + expect(preferenceTreeLabelprovider['formatString'](testString)).eq('Некоторые Настройки'); }); - it('PreferenceTreeGenerator.format.Armenian', () => { + it('PreferenceTreeLabelProvider.format.Armenian', () => { const testString = 'ինչ-որՊարամետր'; - expect(preferenceTreeGenerator['formatString'](testString)).eq('Ինչ-որ Պարամետր'); + expect(preferenceTreeLabelprovider['formatString'](testString)).eq('Ինչ-որ Պարամետր'); }); - it('PreferenceTreeGenerator.format.specialCharacters', () => { + it('PreferenceTreeLabelProvider.format.specialCharacters', () => { const testString = 'hyphenated-wordC++Setting'; - expect(preferenceTreeGenerator['formatString'](testString)).eq('Hyphenated-word C++ Setting'); + expect(preferenceTreeLabelprovider['formatString'](testString)).eq('Hyphenated-word C++ Setting'); }); - describe('PreferenceTreeGenerator.createLeafNode', () => { + describe('PreferenceTreeLabelProvider.createLeafNode', () => { it('when property constructs of three parts the third part is the leaf', () => { const property = 'category-name.subcategory.leaf'; const expectedName = 'Leaf'; @@ -100,18 +93,17 @@ describe('preference-tree-generator', () => { }); function testLeafName(property: string, expectedName: string): void { - const preferencesGroups: CompositeTreeNode[] = []; - const root = preferenceTreeGenerator['createRootNode'](preferencesGroups); - const preferencesGroup = preferenceTreeGenerator['createPreferencesGroup']('group', root); - - const expectedSelectableTreeNode = { - id: property, - name: expectedName, - parent: preferencesGroup, + + const expectedSelectableTreeNode: Preference.LeafNode & SelectableTreeNode = { + id: `group@${property}`, + parent: undefined, visible: true, selected: false, + depth: 2, + preference: { data: {} } }; - expect(preferenceTreeGenerator['createLeafNode'](property, preferencesGroup)).deep.eq(expectedSelectableTreeNode); + + expect(preferenceTreeLabelprovider['getName'](expectedSelectableTreeNode)).deep.eq(expectedName); } }); diff --git a/packages/preferences/src/browser/util/preference-tree-label-provider.ts b/packages/preferences/src/browser/util/preference-tree-label-provider.ts new file mode 100644 index 0000000000000..a0f7df0704ed6 --- /dev/null +++ b/packages/preferences/src/browser/util/preference-tree-label-provider.ts @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (C) 2021 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, inject } from '@theia/core/shared/inversify'; +import { LabelProviderContribution, TreeNode } from '@theia/core/lib/browser'; +import { Preference } from './preference-types'; +import { PreferenceTreeGenerator } from './preference-tree-generator'; +@injectable() +export class PreferenceTreeLabelProvider implements LabelProviderContribution { + @inject(PreferenceTreeGenerator) protected readonly treeGenerator: PreferenceTreeGenerator; + + canHandle(element: object): number { + return TreeNode.is(element) && Preference.TreeNode.is(element) ? 150 : 0; + } + + getName(node: Preference.TreeNode): string { + const { id } = Preference.TreeNode.getGroupAndIdFromNodeId(node.id); + return this.formatString(this.treeGenerator.getCustomLabelFor(id) ?? id.split('.').pop()!); + } + + getPrefix(node: Preference.TreeNode, fullPath = false): string | undefined { + const { depth } = node; + const { id, group } = Preference.TreeNode.getGroupAndIdFromNodeId(node.id); + const segments = id.split('.'); + const segmentsHandled = group === segments[0] ? depth : depth - 1; + segments.pop(); // Ignore the leaf name. + const prefixSegments = fullPath ? segments : segments.slice(segmentsHandled); + if (prefixSegments.length) { + let output = prefixSegments.length > 1 ? `${this.formatString(prefixSegments[0])} › ` : `${this.formatString(prefixSegments[0])}: `; + for (const segment of prefixSegments.slice(1)) { + output += `${this.formatString(segment)}: `; + } + return output; + } + } + + protected formatString(string: string): string { + let formatedString = string[0].toLocaleUpperCase(); + for (let i = 1; i < string.length; i++) { + if (this.isUpperCase(string[i]) && !/\s/.test(string[i - 1]) && !this.isUpperCase(string[i - 1])) { + formatedString += ' '; + } + formatedString += string[i]; + } + return formatedString.trim(); + } + + protected isUpperCase(char: string): boolean { + return char === char.toLocaleUpperCase() && char.toLocaleLowerCase() !== char.toLocaleUpperCase(); + } +} diff --git a/packages/preferences/src/browser/util/preference-types.ts b/packages/preferences/src/browser/util/preference-types.ts index e72ec9f4923fc..d039fdbd60208 100644 --- a/packages/preferences/src/browser/util/preference-types.ts +++ b/packages/preferences/src/browser/util/preference-types.ts @@ -14,29 +14,18 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { PreferenceDataProperty, PreferenceItem, Title, PreferenceScope, TreeNode } from '@theia/core/lib/browser'; +import { + PreferenceDataProperty, + PreferenceScope, + TreeNode as BaseTreeNode, + CompositeTreeNode as BaseCompositeTreeNode, + PreferenceInspection, +} from '@theia/core/lib/browser'; import { Command, MenuPath } from '@theia/core'; +import { JSONValue } from '@theia/core/shared/@phosphor/coreutils'; export namespace Preference { - export interface ValueInSingleScope { value?: PreferenceItem, data: PreferenceDataProperty; } - export interface NodeWithValueInSingleScope extends TreeNode { - preference: ValueInSingleScope; - } - - export interface ValuesInAllScopes { - preferenceName: string; - defaultValue: PreferenceItem | undefined; - globalValue: PreferenceItem | undefined; - workspaceValue: PreferenceItem | undefined; - workspaceFolderValue: PreferenceItem | undefined; - } - - export interface PreferenceWithValueInAllScopes { - values?: ValuesInAllScopes; - data: PreferenceDataProperty; - } - export interface EditorCommandArgs { id: string; value: string | undefined; @@ -48,11 +37,39 @@ export namespace Preference { } } - export interface NodeWithValueInAllScopes extends TreeNode { - preference: PreferenceWithValueInAllScopes; + export const Node = Symbol('Preference.Node'); + export type Node = TreeNode; + + export type TreeNode = CompositeTreeNode | LeafNode; + + export namespace TreeNode { + export const is = (node: BaseTreeNode | TreeNode): node is TreeNode => 'depth' in node; + export const isTopLevel = (node: BaseTreeNode): boolean => { + const { group, id } = getGroupAndIdFromNodeId(node.id); + return group === id; + }; + export const getGroupAndIdFromNodeId = (nodeId: string): { group: string; id: string } => { + const separator = nodeId.indexOf('@'); + const group = nodeId.substring(0, separator); + const id = nodeId.substring(separator + 1, nodeId.length); + return { group, id }; + }; + } + + export interface CompositeTreeNode extends BaseCompositeTreeNode { + depth: number; + } + + export interface LeafNode extends BaseTreeNode { + depth: number; + preference: { data: PreferenceDataProperty }; + } + + export namespace LeafNode { + export const is = (node: BaseTreeNode | LeafNode): node is LeafNode => 'preference' in node && !!node.preference.data; } - export const getValueInScope = (preferenceInfo: ValuesInAllScopes | undefined, scope: number): PreferenceItem | undefined => { + export const getValueInScope = (preferenceInfo: PreferenceInspection | undefined, scope: number): T | undefined => { if (!preferenceInfo) { return undefined; } @@ -68,16 +85,16 @@ export namespace Preference { } }; - export interface SelectedScopeDetails extends Title.Dataset { - scope: string; - uri: string; - activeScopeIsFolder: string; + export interface SelectedScopeDetails { + scope: number; + uri: string | undefined; + activeScopeIsFolder: boolean; }; export const DEFAULT_SCOPE: SelectedScopeDetails = { - scope: PreferenceScope.User.toString(), - uri: '', - activeScopeIsFolder: 'false' + scope: PreferenceScope.User, + uri: undefined, + activeScopeIsFolder: false }; export interface ContextMenuCallbacks { diff --git a/packages/preferences/src/browser/views/components/index.tsx b/packages/preferences/src/browser/views/components/index.tsx deleted file mode 100644 index 76494e5c09e76..0000000000000 --- a/packages/preferences/src/browser/views/components/index.tsx +++ /dev/null @@ -1,22 +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 - ********************************************************************************/ - -export { PreferenceArrayInput } from './preference-array-input'; -export { PreferenceBooleanInput } from './preference-boolean-input'; -export { PreferenceJSONInput } from './preference-json-input'; -export { PreferenceNumberInput } from './preference-number-input'; -export { PreferenceSelectInput } from './preference-select-input'; -export { PreferenceStringInput } from './preference-string-input'; diff --git a/packages/preferences/src/browser/views/components/preference-array-input.ts b/packages/preferences/src/browser/views/components/preference-array-input.ts new file mode 100644 index 0000000000000..6411866378f7c --- /dev/null +++ b/packages/preferences/src/browser/views/components/preference-array-input.ts @@ -0,0 +1,157 @@ +/******************************************************************************** + * Copyright (C) 2021 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 '@theia/core/shared/inversify'; +import { PreferenceLeafNodeRenderer } from './preference-node-renderer'; + +@injectable() +export class PreferenceArrayInputRenderer extends PreferenceLeafNodeRenderer { + existingValues = new Map(); + wrapper: HTMLElement; + inputWrapper: HTMLElement; + + protected createInteractable(parent: HTMLElement): void { + const wrapper = document.createElement('ul'); + wrapper.classList.add('preference-array'); + this.wrapper = wrapper; + const currentValue = this.getValue(); + if (Array.isArray(currentValue)) { + for (const [index, value] of currentValue.entries()) { + const node = this.createExistingValue(value); + wrapper.appendChild(node); + this.existingValues.set(value, { node, index }); + } + } + const inputWrapper = this.createInput(); + wrapper.appendChild(inputWrapper); + parent.appendChild(wrapper); + } + + protected getFallbackValue(): string[] { + return []; + } + + protected createExistingValue(value: string): HTMLElement { + const existingValue = document.createElement('li'); + existingValue.classList.add('preference-array-element'); + const valueWrapper = document.createElement('span'); + valueWrapper.classList.add('preference-array-element-val'); + valueWrapper.textContent = value; + existingValue.appendChild(valueWrapper); + const iconWrapper = document.createElement('span'); + iconWrapper.classList.add('preference-array-element-btn', 'remove-btn'); + const handler = this.removeItem.bind(this, value); + iconWrapper.onclick = handler; + iconWrapper.onkeydown = handler; + iconWrapper.setAttribute('role', 'button'); + iconWrapper.tabIndex = 0; + existingValue.appendChild(iconWrapper); + const icon = document.createElement('i'); + icon.classList.add('preference-array-clear-item'); + iconWrapper.appendChild(icon); + return existingValue; + } + + protected createInput(): HTMLElement { + const inputWrapper = document.createElement('li'); + this.inputWrapper = inputWrapper; + const input = document.createElement('input'); + inputWrapper.appendChild(input); + this.interactable = input; + input.classList.add('preference-array-input', 'theia-input'); + input.type = 'text'; + input.placeholder = 'Add Value...'; + input.onkeydown = this.handleEnter.bind(this); + input.setAttribute('aria-label', 'Preference String Input'); + const iconWrapper = document.createElement('span'); + inputWrapper.appendChild(iconWrapper); + iconWrapper.classList.add('preference-array-element-btn', 'add-btn'); + iconWrapper.setAttribute('role', 'button'); + const handler = this.addItem.bind(this); + iconWrapper.onclick = handler; + iconWrapper.onkeydown = handler; + iconWrapper.tabIndex = 0; + iconWrapper.setAttribute('aria-label', 'Submit Preference Input'); + const icon = document.createElement('i'); + icon.classList.add('fa', 'fa-plus'); + iconWrapper.appendChild(icon); + return inputWrapper; + } + + protected doHandleValueChange(): void { + this.updateInspection(); + const values = this.getValue(); + const newValues = new Set(...values); + for (const [value, row] of this.existingValues.entries()) { + if (!newValues.has(value)) { + row.node.remove(); + this.existingValues.delete(value); + } + } + for (const [index, value] of values.entries()) { + let row = this.existingValues.get(value); + if (row) { + row.index = index; + } else { + row = { node: this.createExistingValue(value), index }; + this.existingValues.set(value, row); + } + + if (this.wrapper.children[index] !== row.node) { + this.wrapper.children[index].insertAdjacentElement('beforebegin', row.node); + } + } + this.updateModificationStatus(); + } + + protected removeItem(value: string): void { + const row = this.existingValues.get(value); + if (row) { + row.node.remove(); + this.existingValues.delete(value); + this.setPreferenceImmediately(this.getOrderedValues()); + } + } + + protected handleEnter(e: KeyboardEvent): void { + if (e.key === 'Enter') { + e.preventDefault(); + this.addItem(); + } + } + + protected addItem(): void { + const newItem = this.interactable.value; + if (newItem && !this.existingValues.has(newItem)) { + const node = this.createExistingValue(newItem); + this.inputWrapper.insertAdjacentElement('beforebegin', node); + this.existingValues.set(newItem, { node, index: this.existingValues.size }); + this.setPreferenceImmediately(this.getOrderedValues()); + } + this.interactable.value = ''; + } + + protected getOrderedValues(): string[] { + return Array.from(this.existingValues.entries()) + .sort(([, a], [, b]) => a.index - b.index) + .map(([value]) => value); + } + + dispose(): void { + this.existingValues.clear(); + super.dispose(); + } +} diff --git a/packages/preferences/src/browser/views/components/preference-array-input.tsx b/packages/preferences/src/browser/views/components/preference-array-input.tsx deleted file mode 100644 index 2e86f8dd5a233..0000000000000 --- a/packages/preferences/src/browser/views/components/preference-array-input.tsx +++ /dev/null @@ -1,113 +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 * as React from '@theia/core/shared/react'; -import { Preference } from '../../util/preference-types'; - -interface PreferenceArrayInputProps { - preferenceDisplayNode: Preference.NodeWithValueInSingleScope; - setPreference(preferenceName: string, preferenceValue: string[]): void; -} - -export const PreferenceArrayInput: React.FC = ({ preferenceDisplayNode, setPreference }) => { - const values: string[] = []; - if (Array.isArray(preferenceDisplayNode.preference.value)) { - for (const preferenceValue of preferenceDisplayNode.preference.value) { - if (typeof preferenceValue === 'string') { - values.push(preferenceValue); - } - } - } - const { id: preferenceID } = preferenceDisplayNode; - const [value, setValue] = React.useState(''); - - const doSubmit = React.useCallback((): void => { - if (value) { - setPreference(preferenceID, [...values, value]); - setValue(''); - } - }, [values, value]); - - const handleEnter = React.useCallback((e: React.KeyboardEvent): void => { - if (e.key === 'Enter') { - e.preventDefault(); - doSubmit(); - } - }, []); - - const handleChange = React.useCallback((e: React.ChangeEvent): void => { - setValue(e.target.value); - }, []); - - const handleRemove = React.useCallback((e: React.MouseEvent | React.KeyboardEvent): void => { - const target = e.currentTarget as HTMLSpanElement; - const key = (e as React.KeyboardEvent).key; - if (key && key !== 'Enter') { - return; - } - - const indexAttribute = target.getAttribute('data-index'); - const removalIndex = Number(indexAttribute); - if (indexAttribute) { - const newValues = [...values.slice(0, removalIndex), ...values.slice(removalIndex + 1)]; - setPreference(preferenceID, newValues); - } - }, []); - - return ( -
    - { - values.map((val: string, i: number): JSX.Element => ( -
  • - {val} - - - -
  • - )) - } -
  • - - - - -
  • -
- ); -}; diff --git a/packages/preferences/src/browser/views/components/preference-boolean-input.ts b/packages/preferences/src/browser/views/components/preference-boolean-input.ts new file mode 100644 index 0000000000000..c1e3e4d38b2f9 --- /dev/null +++ b/packages/preferences/src/browser/views/components/preference-boolean-input.ts @@ -0,0 +1,49 @@ +/******************************************************************************** + * Copyright (C) 2021 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 '@theia/core/shared/inversify'; +import { PreferenceLeafNodeRenderer } from './preference-node-renderer'; + +@injectable() +export class PreferenceBooleanInputRenderer extends PreferenceLeafNodeRenderer { + protected createInteractable(parent: HTMLElement): void { + const interactable = document.createElement('input'); + this.interactable = interactable; + interactable.type = 'checkbox'; + interactable.classList.add('theia-input'); + interactable.defaultChecked = this.getValue(); + interactable.onchange = this.handleUserInteraction.bind(this); + parent.appendChild(interactable); + } + + protected getFallbackValue(): false { + return false; + } + + protected handleUserInteraction(): Promise { + return this.setPreferenceImmediately(this.interactable.checked); + } + + protected doHandleValueChange(): void { + const currentValue = this.interactable.checked; + this.updateInspection(); + const newValue = this.getValue(); + this.updateModificationStatus(newValue); + if (newValue !== currentValue && document.activeElement !== this.interactable) { + this.interactable.checked = newValue; + } + } +} diff --git a/packages/preferences/src/browser/views/components/preference-boolean-input.tsx b/packages/preferences/src/browser/views/components/preference-boolean-input.tsx deleted file mode 100644 index 64d13fc3dac30..0000000000000 --- a/packages/preferences/src/browser/views/components/preference-boolean-input.tsx +++ /dev/null @@ -1,55 +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 * as React from '@theia/core/shared/react'; -import { Preference } from '../../util/preference-types'; - -interface PreferenceBooleanInputProps { - preferenceDisplayNode: Preference.NodeWithValueInSingleScope; - setPreference: (preferenceName: string, preferenceValue: boolean) => void; -} - -export const PreferenceBooleanInput: React.FC = ({ preferenceDisplayNode, setPreference }) => { - const { id } = preferenceDisplayNode; - const value = typeof preferenceDisplayNode.preference.value === 'boolean' ? preferenceDisplayNode.preference.value : undefined; - - // Tracks local state for quicker refreshes on user click. - const [checked, setChecked] = React.useState(!!value); - - // Allows user to reset value using cogwheel. - React.useEffect(() => { - setChecked(!!value); - }, [value]); - - const setValue = React.useCallback((e: React.ChangeEvent) => { - setChecked(!checked); - setPreference(id, e.target.checked); - }, [checked]); - - return ( - - ); -}; diff --git a/packages/preferences/src/browser/views/components/preference-json-input.ts b/packages/preferences/src/browser/views/components/preference-json-input.ts new file mode 100644 index 0000000000000..b98e5651716c8 --- /dev/null +++ b/packages/preferences/src/browser/views/components/preference-json-input.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (C) 2021 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 { PreferenceLeafNodeRenderer } from './preference-node-renderer'; +import { injectable, inject } from '@theia/core/shared/inversify'; +import { CommandService } from '@theia/core/lib/common'; +import { PreferencesCommands } from '../../util/preference-types'; +import { JSONValue } from '@theia/core/shared/@phosphor/coreutils'; + +@injectable() +export class PreferenceJSONLinkRenderer extends PreferenceLeafNodeRenderer { + @inject(CommandService) protected readonly commandService: CommandService; + + protected createInteractable(parent: HTMLElement): void { + const message = 'Edit in settings.json'; + const interactable = document.createElement('a'); + this.interactable = interactable; + interactable.classList.add('theia-json-input'); + interactable.setAttribute('role', 'button'); + interactable.title = message; + interactable.textContent = message; + interactable.onclick = this.handleUserInteraction.bind(this); + interactable.onkeydown = this.handleUserInteraction.bind(this); + parent.appendChild(interactable); + } + + protected getFallbackValue(): JSONValue { + const node = this.preferenceNode; + const type = Array.isArray(node.preference.data.type) ? node.preference.data.type[0] : node.preference.data.type; + switch (type) { + case 'object': + return {}; + case 'array': + return []; + case 'null': + return null; // eslint-disable-line no-null/no-null + default: // Should all be handled by other input types. + return ''; + } + } + + protected doHandleValueChange(): void { + this.updateInspection(); + this.updateModificationStatus(); + } + + protected handleUserInteraction(): void { + this.commandService.executeCommand(PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR.id, this.id); + } +} diff --git a/packages/preferences/src/browser/views/components/preference-json-input.tsx b/packages/preferences/src/browser/views/components/preference-json-input.tsx deleted file mode 100644 index 9be33be6bfe74..0000000000000 --- a/packages/preferences/src/browser/views/components/preference-json-input.tsx +++ /dev/null @@ -1,34 +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 * as React from '@theia/core/shared/react'; -import { Preference } from '../../util/preference-types'; - -interface PreferenceJSONInputProps { - preferenceDisplayNode: Preference.NodeWithValueInSingleScope; - onClick(): void; -} - -export const PreferenceJSONInput: React.FC = ({ preferenceDisplayNode, onClick }) => ( - - Edit in settings.json - -); diff --git a/packages/preferences/src/browser/views/components/preference-node-renderer.ts b/packages/preferences/src/browser/views/components/preference-node-renderer.ts new file mode 100644 index 0000000000000..46453e91448b4 --- /dev/null +++ b/packages/preferences/src/browser/views/components/preference-node-renderer.ts @@ -0,0 +1,426 @@ +/******************************************************************************** + * 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, inject, postConstruct } from '@theia/core/shared/inversify'; +import { PreferenceService, ContextMenuRenderer, PreferenceInspection, PreferenceScope, PreferenceProvider } from '@theia/core/lib/browser'; +import { Preference, PreferenceMenus } from '../../util/preference-types'; +import { PreferenceTreeLabelProvider } from '../../util/preference-tree-label-provider'; +import { PreferencesScopeTabBar } from '../preference-scope-tabbar-widget'; +import { Disposable } from '@theia/core/lib/common'; +import { JSONValue } from '@theia/core/shared/@phosphor/coreutils'; +import debounce = require('@theia/core/shared/lodash.debounce'); +import { PreferenceTreeModel } from '../../preference-tree-model'; +import { PreferencesSearchbarWidget } from '../preference-searchbar-widget'; + +export const PreferenceNodeRendererFactory = Symbol('PreferenceNodeRendererFactory'); +export type PreferenceNodeRendererFactory = (node: Preference.TreeNode) => PreferenceNodeRenderer; +export const HEADER_CLASS = 'settings-section-category-title'; +export const SUBHEADER_CLASS = 'settings-section-subcategory-title'; + +export interface GeneralPreferenceNodeRenderer extends Disposable { + node: HTMLElement; + id: string; + group: string; + nodeId: string; + visible: boolean; + insertBefore(nextSibling: HTMLElement): void; + insertAfter(previousSibling: HTMLElement): void; + appendTo(parent: HTMLElement): void; + prependTo(parent: HTMLElement): void; + handleValueChange?(): void; + handleSearchChange?(isFiltered?: boolean): void; + handleScopeChange?(isFiltered?: boolean): void; + hide(): void; + show(): void; +} + +@injectable() +export abstract class PreferenceNodeRenderer implements Disposable, GeneralPreferenceNodeRenderer { + @inject(Preference.Node) protected readonly preferenceNode: Preference.Node; + @inject(PreferenceTreeLabelProvider) protected readonly labelProvider: PreferenceTreeLabelProvider; + + protected attached = false; + + _id: string; + _group: string; + _subgroup: string; + protected domNode: HTMLElement; + + get node(): HTMLElement { + return this.domNode; + } + + get nodeId(): string { + return this.preferenceNode.id; + } + + get id(): string { + return this._id; + } + + get group(): string { + return this._group; + } + + get visible(): boolean { + return !this.node.classList.contains('hidden'); + } + + @postConstruct() + protected init(): void { + this.setId(); + this.domNode = this.createDomNode(); + } + + protected setId(): void { + const { id, group } = Preference.TreeNode.getGroupAndIdFromNodeId(this.preferenceNode.id); + const segments = id.split('.'); + this._id = id; + this._group = group; + this._subgroup = (group === segments[0] ? segments[1] : segments[0]) ?? ''; + } + + protected abstract createDomNode(): HTMLElement; + + insertBefore(nextSibling: HTMLElement): void { + nextSibling.insertAdjacentElement('beforebegin', this.domNode); + this.attached = true; + } + + insertAfter(previousSibling: HTMLElement): void { + previousSibling.insertAdjacentElement('afterend', this.domNode); + } + + appendTo(parent: HTMLElement): void { + parent.appendChild(this.domNode); + } + + prependTo(parent: HTMLElement): void { + parent.prepend(this.domNode); + } + + hide(): void { + this.domNode.classList.add('hidden'); + } + + show(): void { + this.domNode.classList.remove('hidden'); + } + + dispose(): void { + this.domNode.remove(); + } +} + +export class PreferenceHeaderRenderer extends PreferenceNodeRenderer { + protected createDomNode(): HTMLElement { + const wrapper = document.createElement('ul'); + wrapper.className = 'settings-section'; + wrapper.id = `${this.preferenceNode.id}-editor`; + const isCategory = Preference.TreeNode.isTopLevel(this.preferenceNode); + const hierarchyClassName = isCategory ? HEADER_CLASS : SUBHEADER_CLASS; + const name = this.labelProvider.getName(this.preferenceNode); + const label = document.createElement('li'); + label.classList.add('settings-section-title', hierarchyClassName); + label.textContent = name; + wrapper.appendChild(label); + return wrapper; + } +} + +@injectable() +export abstract class PreferenceLeafNodeRenderer + extends PreferenceNodeRenderer + implements Required { + @inject(Preference.Node) protected readonly preferenceNode: Preference.LeafNode; + @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + @inject(ContextMenuRenderer) protected readonly menuRenderer: ContextMenuRenderer; + @inject(PreferencesScopeTabBar) protected readonly scopeTracker: PreferencesScopeTabBar; + @inject(PreferenceTreeModel) protected readonly model: PreferenceTreeModel; + @inject(PreferencesSearchbarWidget) protected readonly searchbar: PreferencesSearchbarWidget; + + protected headlineWrapper: HTMLDivElement; + protected gutter: HTMLDivElement; + protected interactable: InteractableType; + protected inspection: PreferenceInspection | undefined; + protected isModifiedFromDefault = false; + + @postConstruct() + protected init(): void { + this.setId(); + this.updateInspection(); + this.domNode = this.createDomNode(); + this.updateModificationStatus(); + } + + protected updateInspection(): void { + this.inspection = this.preferenceService.inspect(this.id, this.scopeTracker.currentScope.uri); + } + + protected createDomNode(): HTMLLIElement { + const wrapper = document.createElement('li'); + wrapper.classList.add('single-pref'); + wrapper.id = `${this.id}-editor`; + wrapper.setAttribute('data-pref-id', this.id); + wrapper.setAttribute('data-node-id', this.preferenceNode.id); + + const headlineWrapper = document.createElement('div'); + headlineWrapper.classList.add('pref-name'); + headlineWrapper.title = this.id; + this.headlineWrapper = headlineWrapper; + wrapper.appendChild(headlineWrapper); + + this.updateHeadline(); + + const gutter = document.createElement('div'); + gutter.classList.add('pref-context-gutter'); + this.gutter = gutter; + wrapper.appendChild(gutter); + + const cog = document.createElement('i'); + cog.className = 'codicon codicon-settings-gear settings-context-menu-btn'; + cog.setAttribute('aria-label', 'Open Context Menu'); + cog.setAttribute('role', 'button'); + cog.onclick = this.handleCogAction.bind(this); + cog.onkeydown = this.handleCogAction.bind(this); + cog.title = 'More actions...'; + gutter.appendChild(cog); + + const activeType = Array.isArray(this.preferenceNode.preference.data.type) ? this.preferenceNode.preference.data.type[0] : this.preferenceNode.preference.data.type; + const contentWrapper = document.createElement('div'); + contentWrapper.classList.add('pref-content-container', activeType ?? 'open-json'); + wrapper.appendChild(contentWrapper); + + const { description, markdownDescription } = this.preferenceNode.preference.data; + const descriptionToUse = markdownDescription || description; + if (descriptionToUse) { + const descriptionWrapper = document.createElement('div'); + descriptionWrapper.classList.add('pref-description'); + descriptionWrapper.textContent = descriptionToUse; + contentWrapper.appendChild(descriptionWrapper); + } + + const interactableWrapper = document.createElement('div'); + interactableWrapper.classList.add('pref-input'); + contentWrapper.appendChild(interactableWrapper); + this.createInteractable(interactableWrapper); + + return wrapper; + } + + protected handleCogAction({ currentTarget }: KeyboardEvent | MouseEvent): void { + const value = Preference.getValueInScope(this.inspection, this.scopeTracker.currentScope.scope) ?? this.inspection?.defaultValue; + const target = currentTarget as HTMLElement | undefined; + if (target && value !== undefined) { + this.showCog(); + const domRect = target.getBoundingClientRect(); + this.menuRenderer.render({ + menuPath: PreferenceMenus.PREFERENCE_EDITOR_CONTEXT_MENU, + anchor: { x: domRect.left, y: domRect.bottom }, + args: [{ id: this.id, value }], + onHide: () => this.hideCog() + }); + } + } + + protected addModifiedMarking(): void { + this.gutter.classList.add('theia-mod-item-modified'); + } + + protected removeModifiedMarking(): void { + this.gutter.classList.remove('theia-mod-item-modified'); + } + + protected showCog(): void { + this.gutter.classList.add('show-cog'); + } + + protected hideCog(): void { + this.gutter.classList.remove('show-cog'); + } + + protected updateModificationStatus(knownCurrentValue?: JSONValue): void { + const wasModified = this.isModifiedFromDefault; + const { inspection } = this; + const valueInCurrentScope = knownCurrentValue ?? Preference.getValueInScope(inspection, this.scopeTracker.currentScope.scope); + this.isModifiedFromDefault = valueInCurrentScope !== undefined && !PreferenceProvider.deepEqual(valueInCurrentScope, inspection?.defaultValue); + if (wasModified !== this.isModifiedFromDefault) { + this.gutter.classList.toggle('theia-mod-item-modified', this.isModifiedFromDefault); + } + } + + protected updateHeadline(filtered = this.model.isFiltered): void { + const { headlineWrapper } = this; + if (this.headlineWrapper.childElementCount === 0) { + const name = this.labelProvider.getName(this.preferenceNode); + const nameWrapper = document.createElement('span'); + nameWrapper.classList.add('preference-leaf-headline-name'); + nameWrapper.textContent = name; + headlineWrapper.appendChild(nameWrapper); + } + const prefix = this.labelProvider.getPrefix(this.preferenceNode, filtered); + const currentFirstChild = headlineWrapper.children[0]; + const currentFirstChildIsPrefix = currentFirstChild.classList.contains('preference-leaf-headline-prefix'); + if (prefix) { + let prefixWrapper; + if (currentFirstChildIsPrefix) { + prefixWrapper = currentFirstChild; + } else { + prefixWrapper = document.createElement('span'); + prefixWrapper.classList.add('preference-leaf-headline-prefix'); + headlineWrapper.insertBefore(prefixWrapper, currentFirstChild); + } + prefixWrapper.textContent = prefix; + } else if (currentFirstChildIsPrefix) { + headlineWrapper.removeChild(currentFirstChild); + } + + const currentLastChild = headlineWrapper.lastChild as HTMLElement; + if (currentLastChild.classList.contains('preference-leaf-headline-suffix')) { + this.compareOtherModifiedScopes(headlineWrapper, currentLastChild); + } else { + this.createOtherModifiedScopes(headlineWrapper); + } + } + + protected compareOtherModifiedScopes(headlineWrapper: HTMLDivElement, currentSuffix: HTMLElement): void { + const modifiedScopes = this.getModifiedScopesAsStrings(); + if (modifiedScopes.length === 0) { + headlineWrapper.removeChild(currentSuffix); + } else { + + const modifiedMessagePrefix = currentSuffix.children[0] as HTMLElement; + const newMessagePrefix = this.getModifiedMessagePrefix(); + if (modifiedMessagePrefix.textContent !== newMessagePrefix) { + modifiedMessagePrefix.textContent = newMessagePrefix; + } + + const [firstModifiedScope, secondModifiedScope] = modifiedScopes; + + const firstScopeMessage = currentSuffix.children[1] as HTMLElement; + const secondScopeMessage = currentSuffix.children[2] as HTMLElement; + firstScopeMessage.children[0].textContent = PreferenceScope[firstModifiedScope]; + this.addEventHandlerToModifiedScope(firstModifiedScope, firstScopeMessage.children[0] as HTMLElement); + if (modifiedScopes.length === 1 && secondScopeMessage) { + currentSuffix.removeChild(secondScopeMessage); + } else if (modifiedScopes.length === 2 && !secondScopeMessage) { + const newSecondMessage = this.createModifiedScopeMessage(secondModifiedScope); + currentSuffix.appendChild(newSecondMessage); + } + // If both scopes are modified and both messages are present, do nothing. + } + } + + protected createOtherModifiedScopes(headlineWrapper: HTMLDivElement): void { + const modifiedScopes = this.getModifiedScopesAsStrings(); + if (modifiedScopes.length !== 0) { + const wrapper = document.createElement('i'); + wrapper.classList.add('preference-leaf-headline-suffix'); + headlineWrapper.appendChild(wrapper); + + const messagePrefix = this.getModifiedMessagePrefix(); + const messageWrapper = document.createElement('span'); + messageWrapper.classList.add('preference-other-modified-scope-alert'); + messageWrapper.textContent = messagePrefix; + wrapper.appendChild(messageWrapper); + modifiedScopes.forEach((scopeName, i) => { + const scopeWrapper = this.createModifiedScopeMessage(scopeName); + wrapper.appendChild(scopeWrapper); + }); + } + } + + protected createModifiedScopeMessage(scope: PreferenceScope): HTMLSpanElement { + const scopeWrapper = document.createElement('span'); + scopeWrapper.classList.add('preference-modified-scope-wrapper'); + const scopeInteractable = document.createElement('span'); + scopeInteractable.classList.add('preference-scope-underlined'); + const scopeName = PreferenceScope[scope]; + this.addEventHandlerToModifiedScope(scope, scopeInteractable); + scopeInteractable.textContent = scopeName; + scopeWrapper.appendChild(scopeInteractable); + return scopeWrapper; + } + + protected getModifiedMessagePrefix(): string { + return this.isModifiedFromDefault ? 'Also modified in: ' : 'Modified in: '; + } + + protected addEventHandlerToModifiedScope(scope: PreferenceScope, scopeWrapper: HTMLElement): void { + if (scope === PreferenceScope.User || scope === PreferenceScope.Workspace) { + const eventHandler = () => { + this.scopeTracker.setScope(scope); + this.searchbar.updateSearchTerm(this.id); + }; + scopeWrapper.onclick = eventHandler; + scopeWrapper.onkeydown = eventHandler; + scopeWrapper.tabIndex = 0; + } else { + scopeWrapper.onclick = null; // eslint-disable-line no-null/no-null + scopeWrapper.onkeydown = null; // eslint-disable-line no-null/no-null + scopeWrapper.tabIndex = -1; + } + } + + protected getModifiedScopesAsStrings(): PreferenceScope[] { + const currentScopeInView = this.scopeTracker.currentScope.scope; + const { inspection } = this; + const modifiedScopes = []; + if (inspection) { + for (const otherScope of [PreferenceScope.User, PreferenceScope.Workspace]) { + if (otherScope !== currentScopeInView) { + const valueInOtherScope = Preference.getValueInScope(inspection, otherScope); + if (valueInOtherScope !== undefined && !PreferenceProvider.deepEqual(valueInOtherScope, inspection.defaultValue)) { + modifiedScopes.push(otherScope); + } + } + } + } + return modifiedScopes; + } + + protected getValue(): ValueType { + let currentValue = Preference.getValueInScope(this.inspection, this.scopeTracker.currentScope.scope); + if (currentValue === undefined) { + currentValue = this.inspection?.defaultValue; + } + return currentValue ?? this.getFallbackValue(); + } + + protected setPreferenceWithDebounce = debounce(this.setPreferenceImmediately.bind(this), 500, { leading: false, trailing: true }); + + protected setPreferenceImmediately(value: ValueType | undefined): Promise { + return this.preferenceService.set(this.id, value, this.scopeTracker.currentScope.scope, this.scopeTracker.currentScope.uri); + } + + handleSearchChange(isFiltered = this.model.isFiltered): void { + this.updateHeadline(isFiltered); + } + + handleScopeChange(isFiltered = this.model.isFiltered): void { + this.handleValueChange(); + this.updateHeadline(isFiltered); + } + + handleValueChange(): void { + this.doHandleValueChange(); + this.updateHeadline(); + } + + protected abstract createInteractable(container: HTMLElement): void; + protected abstract getFallbackValue(): ValueType; + protected abstract doHandleValueChange(): void; +} diff --git a/packages/preferences/src/browser/views/components/preference-number-input.ts b/packages/preferences/src/browser/views/components/preference-number-input.ts new file mode 100644 index 0000000000000..4e864a6df1368 --- /dev/null +++ b/packages/preferences/src/browser/views/components/preference-number-input.ts @@ -0,0 +1,128 @@ +/******************************************************************************** + * Copyright (C) 2021 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 '@theia/core/shared/inversify'; +import { PreferenceLeafNodeRenderer } from './preference-node-renderer'; + +interface PreferenceNumberInputValidation { + /** + * the numeric value of the input. `NaN` if there is an error. + */ + value: number; + /** + * the error message to display. + */ + message: string; +} + +@injectable() +export class PreferenceNumberInputRenderer extends PreferenceLeafNodeRenderer { + + protected _errorMessage: HTMLElement | undefined; + protected interactableWrapper: HTMLElement; + + get errorMessage(): HTMLElement { + if (!this._errorMessage) { + const errorMessage = document.createElement('div'); + errorMessage.classList.add('pref-error-notification'); + this._errorMessage = errorMessage; + } + return this._errorMessage; + } + + protected createInteractable(parent: HTMLElement): void { + const interactableWrapper = document.createElement('div'); + this.interactableWrapper = interactableWrapper; + interactableWrapper.classList.add('pref-input-container'); + const interactable = document.createElement('input'); + this.interactable = interactable; + interactable.type = 'number'; + interactable.classList.add('theia-input'); + interactable.defaultValue = this.getValue().toString(); + interactable.oninput = this.handleUserInteraction.bind(this); + interactable.onblur = this.handleBlur.bind(this); + interactableWrapper.appendChild(interactable); + parent.appendChild(interactableWrapper); + } + + protected getFallbackValue(): number { + return 0; + } + + protected handleUserInteraction(): void { + const { value, message } = this.getInputValidation(this.interactable.value); + if (isNaN(value)) { + this.showErrorMessage(message); + } else { + this.hideErrorMessage(); + this.setPreferenceWithDebounce(value); + } + } + + protected async handleBlur(): Promise { + this.hideErrorMessage(); + await this.setPreferenceWithDebounce.flush(); + this.handleValueChange(); + } + + protected doHandleValueChange(): void { + const { value } = this.interactable; + const currentValue = value.length ? Number(value) : NaN; + this.updateInspection(); + const newValue = this.getValue(); + this.updateModificationStatus(newValue); + if (newValue !== currentValue) { + if (document.activeElement !== this.interactable) { + this.interactable.value = newValue.toString(); + } else { + this.handleUserInteraction(); // give priority to the value of the input if it is focused. + } + } + } + + protected getInputValidation(input: string): PreferenceNumberInputValidation { + const { preference: { data } } = this.preferenceNode; + const inputValue = Number(input); + const errorMessages: string[] = []; + + if (input === '' || isNaN(inputValue)) { + return { value: NaN, message: 'Value must be a number.' }; + } + if (data.minimum && inputValue < data.minimum) { + errorMessages.push(`Value must be greater than or equal to ${data.minimum}.`); + }; + if (data.maximum && inputValue > data.maximum) { + errorMessages.push(`Value must be less than or equal to ${data.maximum}.`); + }; + if (data.type === 'integer' && !Number.isInteger(inputValue)) { + errorMessages.push('Value must be an integer.'); + } + + return { + value: errorMessages.length ? NaN : inputValue, + message: errorMessages.join(' ') + }; + } + + protected showErrorMessage(message: string): void { + this.errorMessage.textContent = message; + this.interactableWrapper.appendChild(this.errorMessage); + } + + protected hideErrorMessage(): void { + this.errorMessage.remove(); + } +} diff --git a/packages/preferences/src/browser/views/components/preference-number-input.tsx b/packages/preferences/src/browser/views/components/preference-number-input.tsx deleted file mode 100644 index 63a4185150e4f..0000000000000 --- a/packages/preferences/src/browser/views/components/preference-number-input.tsx +++ /dev/null @@ -1,101 +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 * as React from '@theia/core/shared/react'; -import { Preference } from '../../util/preference-types'; - -interface PreferenceNumberInputProps { - preferenceDisplayNode: Preference.NodeWithValueInSingleScope; - setPreference(preferenceName: string, preferenceValue: number): void; -} - -export const PreferenceNumberInput: React.FC = ({ preferenceDisplayNode, setPreference }) => { - const { id } = preferenceDisplayNode; - const { data, value } = preferenceDisplayNode.preference; - - const externalValue = (value !== undefined ? value : data.defaultValue) ?? ''; - - const [currentTimeout, setCurrentTimetout] = React.useState(0); - const [currentValue, setCurrentValue] = React.useState(externalValue); - const [validationMessage, setValidationMessage] = React.useState(''); - - React.useEffect(() => { - setCurrentValue(externalValue); - }, [externalValue]); - - const onBlur = React.useCallback(() => { - setCurrentValue(externalValue); - setValidationMessage(''); - }, [externalValue]); - - const onChange = React.useCallback(e => { - clearTimeout(currentTimeout); - const { value: newValue } = e.target; - setCurrentValue(newValue); - const { value: inputValue , message } = getInputValidation(newValue); - setValidationMessage(message); - if (!isNaN(inputValue)) { - const newTimeout = setTimeout(() => setPreference(id, inputValue), 750); - setCurrentTimetout(Number(newTimeout)); - } - }, [currentTimeout]); - - /** - * Validate the input value. - * @param input the input value. - */ - const getInputValidation = (input: string): { - value: number, // the numeric value of the input. `NaN` if there is an error. - message: string // the error message to display. - } => { - const inputValue = Number(input); - const errorMessages: string[] = []; - - if (input === '' || isNaN(inputValue)) { - return { value: NaN, message: 'Value must be a number.' }; - } - if (data.minimum && inputValue < data.minimum) { - errorMessages.push(`Value must be greater than or equal to ${data.minimum}.`); - }; - if (data.maximum && inputValue > data.maximum) { - errorMessages.push(`Value must be less than or equal to ${data.maximum}.`); - }; - if (data.type === 'integer' && inputValue % 1 !== 0) { - errorMessages.push('Value must be an integer.'); - } - - return { - value: errorMessages.length ? NaN : inputValue, - message: errorMessages.join(' ') - }; - }; - - return ( -
- - {!!validationMessage.length ?
{validationMessage}
: undefined} -
- ); -}; diff --git a/packages/preferences/src/browser/views/components/preference-select-input.ts b/packages/preferences/src/browser/views/components/preference-select-input.ts new file mode 100644 index 0000000000000..5386c271b671e --- /dev/null +++ b/packages/preferences/src/browser/views/components/preference-select-input.ts @@ -0,0 +1,55 @@ +/******************************************************************************** + * Copyright (C) 2021 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 { PreferenceLeafNodeRenderer } from './preference-node-renderer'; +import { injectable } from '@theia/core/shared/inversify'; + +@injectable() +export class PreferenceSelectInputRenderer extends PreferenceLeafNodeRenderer { + protected createInteractable(parent: HTMLElement): void { + const options = this.preferenceNode.preference.data.enum!; + const interactable = document.createElement('select'); + this.interactable = interactable; + interactable.classList.add('theia-select'); + interactable.onchange = this.handleUserInteraction.bind(this); + for (const value of options) { + const option = document.createElement('option'); + option.value = value; + option.textContent = value; + interactable.appendChild(option); + } + interactable.value = this.getValue(); + parent.appendChild(interactable); + } + + protected getFallbackValue(): string { + return this.preferenceNode.preference.data.enum![0]; + } + + protected doHandleValueChange(): void { + const currentValue = this.interactable.value || undefined; + this.updateInspection(); + const newValue = this.getValue(); + this.updateModificationStatus(newValue); + if (newValue !== currentValue && document.activeElement !== this.interactable) { + this.interactable.value = newValue; + } + } + + protected handleUserInteraction(): void { + this.setPreferenceImmediately(this.interactable.value || undefined); + } +} diff --git a/packages/preferences/src/browser/views/components/preference-select-input.tsx b/packages/preferences/src/browser/views/components/preference-select-input.tsx deleted file mode 100644 index 835b9e30a0f64..0000000000000 --- a/packages/preferences/src/browser/views/components/preference-select-input.tsx +++ /dev/null @@ -1,57 +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 * as React from '@theia/core/shared/react'; -import { Preference } from '../../util/preference-types'; - -interface PreferenceSelectInputProps { - preferenceDisplayNode: Preference.NodeWithValueInSingleScope; - setPreference: (preferenceName: string, preferenceValue: string | number | string[]) => void; -} - -export const PreferenceSelectInput: React.FC = ({ preferenceDisplayNode, setPreference }) => { - const { id } = preferenceDisplayNode; - const { value, data } = preferenceDisplayNode.preference; - - const externalValue = (value !== undefined ? value : data.defaultValue) || ''; - - const [currentTimeout, setCurrentTimetout] = React.useState(0); - const [currentValue, setCurrentValue] = React.useState(externalValue); - - React.useEffect(() => { - setCurrentValue(externalValue); - }, [externalValue]); - - const onChange = React.useCallback(e => { - const { value: newValue } = e.target; - clearTimeout(currentTimeout); - const newTimeout = setTimeout(() => setPreference(id, newValue), 250); - setCurrentTimetout(Number(newTimeout)); - setCurrentValue(newValue); - }, [currentTimeout]); - - return ( - - ); -}; diff --git a/packages/preferences/src/browser/views/components/preference-string-input.ts b/packages/preferences/src/browser/views/components/preference-string-input.ts new file mode 100644 index 0000000000000..7461fa3c69877 --- /dev/null +++ b/packages/preferences/src/browser/views/components/preference-string-input.ts @@ -0,0 +1,59 @@ +/******************************************************************************** + * Copyright (C) 2021 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 '@theia/core/shared/inversify'; +import { PreferenceLeafNodeRenderer } from './preference-node-renderer'; + +@injectable() +export class PreferenceStringInputRenderer extends PreferenceLeafNodeRenderer { + protected createInteractable(parent: HTMLElement): void { + const interactable = document.createElement('input'); + this.interactable = interactable; + interactable.type = 'text'; + interactable.classList.add('theia-input'); + interactable.defaultValue = this.getValue(); + interactable.oninput = this.handleUserInteraction.bind(this); + interactable.onblur = this.handleBlur.bind(this); + parent.appendChild(interactable); + } + + protected getFallbackValue(): string { + return ''; + } + + protected doHandleValueChange(): void { + const currentValue = this.interactable.value; + this.updateInspection(); + const newValue = this.getValue(); + this.updateModificationStatus(newValue); + if (newValue !== currentValue) { + if (document.activeElement !== this.interactable) { + this.interactable.value = newValue; + } else { + this.handleUserInteraction(); // give priority to the value of the input if it is focused. + } + } + } + + protected handleUserInteraction(): void { + this.setPreferenceWithDebounce(this.interactable.value); + } + + protected async handleBlur(): Promise { + await this.setPreferenceWithDebounce.flush(); + this.handleValueChange(); + } +} diff --git a/packages/preferences/src/browser/views/components/preference-string-input.tsx b/packages/preferences/src/browser/views/components/preference-string-input.tsx deleted file mode 100644 index 57f03ef3da2e8..0000000000000 --- a/packages/preferences/src/browser/views/components/preference-string-input.tsx +++ /dev/null @@ -1,56 +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 * as React from '@theia/core/shared/react'; -import { Preference } from '../../util/preference-types'; - -interface PreferenceStringInputProps { - preferenceDisplayNode: Preference.NodeWithValueInSingleScope; - setPreference(preferenceName: string, preferenceValue: string): void; -} - -export const PreferenceStringInput: React.FC = ({ preferenceDisplayNode, setPreference }) => { - const { id } = preferenceDisplayNode; - const { data, value } = preferenceDisplayNode.preference; - - const externalValue = (value !== undefined ? value : data.defaultValue) || ''; - - const [currentTimeout, setCurrentTimetout] = React.useState(0); - const [currentValue, setCurrentValue] = React.useState(externalValue); - - React.useEffect(() => { - setCurrentValue(externalValue); - }, [externalValue]); - - const onChange = React.useCallback(e => { - const { value: newValue } = e.target; - clearTimeout(currentTimeout); - const newTimeout = setTimeout(() => setPreference(id, newValue), 750); - setCurrentTimetout(Number(newTimeout)); - setCurrentValue(newValue); - }, [currentTimeout]); - - return ( - - ); -}; 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 deleted file mode 100644 index 719de1b5a3b87..0000000000000 --- a/packages/preferences/src/browser/views/components/single-preference-display-factory.tsx +++ /dev/null @@ -1,47 +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 * as React from '@theia/core/shared/react'; -import { injectable, inject } from '@theia/core/shared/inversify'; -import { PreferenceService, ContextMenuRenderer } from '@theia/core/lib/browser'; -import { CommandService } from '@theia/core'; -import { Preference, PreferencesCommands } from '../../util/preference-types'; -import { SinglePreferenceWrapper } from './single-preference-wrapper'; -import { PreferencesScopeTabBar } from '../preference-scope-tabbar-widget'; - -@injectable() -export class SinglePreferenceDisplayFactory { - @inject(PreferenceService) protected readonly preferenceValueRetrievalService: PreferenceService; - @inject(CommandService) protected readonly commandService: CommandService; - @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; - @inject(PreferencesScopeTabBar) protected readonly scopeTracker: PreferencesScopeTabBar; - - protected openJSON = (preferenceNode: Preference.NodeWithValueInAllScopes): void => { - this.commandService.executeCommand(PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR.id, preferenceNode); - }; - - render(preferenceNode: Preference.NodeWithValueInAllScopes): React.ReactElement { - return ; - } -} diff --git a/packages/preferences/src/browser/views/components/single-preference-wrapper.tsx b/packages/preferences/src/browser/views/components/single-preference-wrapper.tsx deleted file mode 100644 index d4f3a8b784ab9..0000000000000 --- a/packages/preferences/src/browser/views/components/single-preference-wrapper.tsx +++ /dev/null @@ -1,209 +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 * as React from '@theia/core/shared/react'; -import { Menu, PreferenceScope, PreferenceItem, PreferenceService, ContextMenuRenderer } from '@theia/core/lib/browser'; -import { PreferenceSelectInput, PreferenceBooleanInput, PreferenceStringInput, PreferenceNumberInput, PreferenceJSONInput, PreferenceArrayInput } from '.'; -import { Preference, PreferenceMenus } from '../../util/preference-types'; - -interface SinglePreferenceWrapperProps { - contextMenuRenderer: ContextMenuRenderer; - preferenceDisplayNode: Preference.NodeWithValueInAllScopes; - currentScope: number; - currentScopeURI: string; - preferencesService: PreferenceService; - openJSON(preferenceNode: Preference.NodeWithValueInAllScopes): void; -} - -interface SinglePreferenceWrapperState { - showCog: boolean; - menuOpen: boolean; -} - -export class SinglePreferenceWrapper extends React.Component { - protected contextMenu: Menu; - protected value: PreferenceItem | undefined; - - state: SinglePreferenceWrapperState = { - showCog: false, - menuOpen: false - }; - - protected handleOnCogClick = (e: React.MouseEvent | React.KeyboardEvent): void => { - if (this.value !== undefined) { - const target = (e.target as HTMLElement); - const domRect = target.getBoundingClientRect(); - this.props.contextMenuRenderer.render({ - menuPath: PreferenceMenus.PREFERENCE_EDITOR_CONTEXT_MENU, - anchor: { x: domRect.left, y: domRect.bottom }, - args: [{ id: this.props.preferenceDisplayNode.id, value: this.value }], - onHide: this.setMenuHidden - }); - this.setMenuShown(); - } - }; - - protected setMenuShown = () => { - this.setState({ menuOpen: true }); - }; - - protected setMenuHidden = () => { - this.setState({ menuOpen: false }); - }; - - protected showCog = () => { - this.setState({ showCog: true }); - }; - - protected hideCog = () => { - this.setState({ showCog: false }); - }; - - render(): React.ReactNode { - const { preferenceDisplayNode } = this.props; - const { preference: { data, values } } = preferenceDisplayNode; - - this.value = Preference.getValueInScope(values, this.props.currentScope) ?? data.defaultValue; - - const currentValueIsDefaultValue = this.value === data.defaultValue; - - const singlePreferenceValueDisplayNode = { ...preferenceDisplayNode, preference: { data, value: this.value } }; - const description = data.markdownDescription || data.description; - if (preferenceDisplayNode.visible) { - return (
  • -
    - {preferenceDisplayNode.name} - {this.renderOtherModifiedScopes(singlePreferenceValueDisplayNode.id, values, this.props.currentScope, this.props.preferencesService)} -
    -
    - -
    -
    - {description &&
    {description}
    } -
    {this.getInputType(singlePreferenceValueDisplayNode)}
    -
    -
  • ); - } else { - return <>; - } - } - - protected openJSONForCurrentPreference = () => { - this.props.openJSON(this.props.preferenceDisplayNode); - }; - - protected renderOtherModifiedScopes( - id: string, - preferenceValuesInAllScopes: Preference.ValuesInAllScopes | undefined, - currentScope: number, - service: PreferenceService): React.ReactNode[] | undefined { - if (preferenceValuesInAllScopes) { - return ['User', 'Workspace'].map((scope: 'User' | 'Workspace') => { - const otherScope = PreferenceScope[scope]; - if (currentScope !== otherScope) { - const info = service.inspect(id); - if (!info) { - return; - } - - const defaultValue = info.defaultValue; - const currentValue = Preference.getValueInScope(info, currentScope); - const otherValue = Preference.getValueInScope(info, otherScope); - if (otherValue !== undefined && otherValue !== defaultValue) { - - const bothOverridden = ( - (currentValue !== defaultValue && currentValue !== undefined) && - (otherValue !== defaultValue && otherValue !== undefined) - ); - - const message = bothOverridden ? 'Also modified in:' : 'Modified in:'; - return ({message} {scope}); - } - - } - }); - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected setPreference = (preferenceName: string, preferenceValue: any): void => { - this.props.preferencesService.set(preferenceName, preferenceValue, this.props.currentScope, this.props.currentScopeURI); - }; - - getInputType = (preferenceDisplayNode: Preference.NodeWithValueInSingleScope): React.ReactNode => { - const { type, items } = preferenceDisplayNode.preference.data; - if (preferenceDisplayNode.preference.data.enum) { - return ; - } if (type === 'boolean') { - return ; - } if (type === 'string') { - return ; - } if (type === 'number' || type === 'integer') { - return ; - } if (type === 'array') { - if (items && items.type === 'string') { - return ; - } - return ; - } if (type === 'object') { - return ; - } - return ; - }; -} diff --git a/packages/preferences/src/browser/views/preference-editor-widget.ts b/packages/preferences/src/browser/views/preference-editor-widget.ts new file mode 100644 index 0000000000000..8b3132b3457e7 --- /dev/null +++ b/packages/preferences/src/browser/views/preference-editor-widget.ts @@ -0,0 +1,316 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { postConstruct, injectable, inject } from '@theia/core/shared/inversify'; +import throttle = require('@theia/core/shared/lodash.throttle'); +import { + PreferenceService, + CompositeTreeNode, + SelectableTreeNode, + StatefulWidget, + TopDownTreeIterator, + PreferenceChanges, + ExpandableTreeNode, +} from '@theia/core/lib/browser'; +import { BaseWidget, DEFAULT_SCROLL_OPTIONS, } from '@theia/core/lib/browser/widgets/widget'; +import { PreferenceTreeModel, PreferenceFilterChangeEvent, PreferenceFilterChangeSource } from '../preference-tree-model'; +import { PreferenceNodeRendererFactory, GeneralPreferenceNodeRenderer } from './components/preference-node-renderer'; +import { Preference } from '../util/preference-types'; +import { COMMONLY_USED_SECTION_PREFIX } from '../util/preference-tree-generator'; +import { PreferencesScopeTabBar } from './preference-scope-tabbar-widget'; + +export interface PreferencesEditorState { + firstVisibleChildID: string, +} + +@injectable() +export class PreferencesEditorWidget extends BaseWidget implements StatefulWidget { + static readonly ID = 'settings.editor'; + static readonly LABEL = 'Settings Editor'; + + scrollOptions = DEFAULT_SCROLL_OPTIONS; + + protected scrollContainer: HTMLDivElement; + /** + * Guards against scroll events and selection events looping into each other. Set before this widget initiates a selection. + */ + protected currentModelSelectionId = ''; + /** + * Permits the user to expand multiple nodes without each one being collapsed on a new selection. + */ + protected lastUserSelection = ''; + protected isAtScrollTop = true; + protected firstVisibleChildID = ''; + protected renderers = new Map(); + // The commonly used section will duplicate preference ID's, so we'll keep a separate list of them. + protected commonlyUsedRenderers = new Map(); + + @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + @inject(PreferenceTreeModel) protected readonly model: PreferenceTreeModel; + @inject(PreferenceNodeRendererFactory) protected readonly rendererFactory: PreferenceNodeRendererFactory; + @inject(PreferencesScopeTabBar) protected readonly tabbar: PreferencesScopeTabBar; + + @postConstruct() + protected init(): void { + this.id = PreferencesEditorWidget.ID; + this.title.label = PreferencesEditorWidget.LABEL; + this.addClass('settings-main'); + this.toDispose.pushAll([ + this.preferenceService.onPreferencesChanged(e => this.handlePreferenceChanges(e)), + this.model.onFilterChanged(e => this.handleFilterChange(e)), + this.model.onSelectionChanged(e => this.handleSelectionChange(e)), + ]); + this.createContainers(); + } + + protected createContainers(): void { + const innerWrapper = document.createElement('div'); + innerWrapper.classList.add('settings-main-scroll-container'); + this.scrollContainer = innerWrapper; + innerWrapper.addEventListener('scroll', this.onScroll, { passive: true }); + this.node.appendChild(innerWrapper); + const noLeavesMessage = document.createElement('div'); + noLeavesMessage.classList.add('settings-no-results-announcement'); + noLeavesMessage.textContent = 'That search query has returned no results.'; + this.node.appendChild(noLeavesMessage); + } + + protected handleFilterChange(e: PreferenceFilterChangeEvent): void { + const { isFiltered } = this.model; + const currentFirstVisible = this.firstVisibleChildID; + if (e.source === PreferenceFilterChangeSource.Search) { + this.handleSearchChange(isFiltered); + } else if (e.source === PreferenceFilterChangeSource.Scope) { + this.handleScopeChange(isFiltered); + } else if (e.source === PreferenceFilterChangeSource.Schema) { + this.handleSchemaChange(isFiltered); + } else { + ((otherSource: never) => { throw new Error('You should handle all possible sources of change!'); })(e.source); + } + this.resetScroll(currentFirstVisible, e.source === PreferenceFilterChangeSource.Search && !isFiltered); + } + + protected handleSchemaChange(isFiltered: boolean): void { + for (const [id, renderer, collection] of this.allRenderers()) { + if (!this.model.getNode(renderer.nodeId)) { + renderer.dispose(); + collection.delete(id); + } + } + if (this.model.root) { + const nodeIterator = Array.from(this.scrollContainer.children)[Symbol.iterator](); + let nextNode: HTMLElement | undefined = nodeIterator.next().value; + for (const node of new TopDownTreeIterator(this.model.root)) { + if (Preference.TreeNode.is(node)) { + const { collection, id } = this.analyzeIDAndGetRendererGroup(node.id); + const renderer = collection.get(id) ?? this.rendererFactory(node); + if (!renderer.node.parentElement) { // If it hasn't been attached yet, it hasn't been checked for the current search. + this.hideIfFailsFilters(renderer, isFiltered); + collection.set(id, renderer); + } + if (nextNode !== renderer.node) { + if (nextNode) { + renderer.insertBefore(nextNode); + } else { + renderer.appendTo(this.scrollContainer); + } + } else { + nextNode = nodeIterator.next().value; + } + } + } + } + } + + protected handleScopeChange(isFiltered: boolean = this.model.isFiltered): void { + for (const [, renderer] of this.allRenderers()) { + const isHidden = this.hideIfFailsFilters(renderer, isFiltered); + if (isFiltered || !isHidden) { + renderer.handleScopeChange?.(isFiltered); + } + } + } + + protected handleSearchChange(isFiltered: boolean): void { + const noLeavesVisible = this.model.totalVisibleLeaves === 0; + this.node.classList.toggle('no-results', noLeavesVisible); + this.scrollContainer.classList.toggle('hidden', noLeavesVisible); + if (!noLeavesVisible) { + for (const [, renderer] of this.allRenderers()) { + const isHidden = this.hideIfFailsFilters(renderer, isFiltered); + if (!isHidden) { + renderer.handleSearchChange?.(isFiltered); + } + } + } + } + + protected *allRenderers(): IterableIterator<[string, GeneralPreferenceNodeRenderer, Map]> { + for (const [id, renderer] of this.commonlyUsedRenderers.entries()) { + yield [id, renderer, this.commonlyUsedRenderers]; + } + for (const [id, renderer] of this.renderers.entries()) { + yield [id, renderer, this.renderers]; + } + } + + protected handlePreferenceChanges(e: PreferenceChanges): void { + for (const id of Object.keys(e)) { + this.commonlyUsedRenderers.get(id)?.handleValueChange?.(); + this.renderers.get(id)?.handleValueChange?.(); + } + } + + /** + * @returns true if the renderer is hidden, false otherwise. + */ + protected hideIfFailsFilters(renderer: GeneralPreferenceNodeRenderer, isFiltered: boolean): boolean { + const row = this.model.currentRows.get(renderer.nodeId); + if (!row || (CompositeTreeNode.is(row.node) && (isFiltered || row.visibleChildren === 0))) { + renderer.hide(); + return true; + } else { + renderer.show(); + return false; + } + } + + protected resetScroll(nodeIDToScrollTo?: string, filterWasCleared: boolean = false): void { + if (this.scrollBar) { // Absent on widget creation + this.doResetScroll(nodeIDToScrollTo, filterWasCleared); + } else { + const interval = setInterval(() => { + if (this.scrollBar) { + clearInterval(interval); + this.doResetScroll(nodeIDToScrollTo, filterWasCleared); + } + }, 500); + } + } + + protected doResetScroll(nodeIDToScrollTo?: string, filterWasCleared: boolean = false): void { + requestAnimationFrame(() => { + this.scrollBar?.update(); + if (!filterWasCleared && nodeIDToScrollTo) { + const { id, collection } = this.analyzeIDAndGetRendererGroup(nodeIDToScrollTo); + const renderer = collection.get(id); + if (renderer?.visible) { + renderer.node.scrollIntoView(); + return; + } + } + this.scrollContainer.scrollTop = 0; + }); + }; + + protected doOnScroll(): void { + const { scrollContainer } = this; + const firstVisibleChildID = this.findFirstVisibleChildID(); + this.setFirstVisibleChildID(firstVisibleChildID); + if (this.isAtScrollTop && scrollContainer.scrollTop !== 0) { + this.isAtScrollTop = false; + this.tabbar.toggleShadow(true); + } else if (!this.isAtScrollTop && scrollContainer.scrollTop === 0) { + this.isAtScrollTop = true; + this.tabbar.toggleShadow(false); + } + }; + + onScroll = throttle(this.doOnScroll.bind(this), 50); + + protected findFirstVisibleChildID(): string | undefined { + const { scrollTop } = this.scrollContainer; + for (const [, renderer] of this.allRenderers()) { + const { offsetTop, offsetHeight } = renderer.node; + if (Math.abs(offsetTop - scrollTop) <= offsetHeight / 2) { + return renderer.nodeId; + } + } + } + + protected setFirstVisibleChildID(id?: string): void { + if (id && id !== this.firstVisibleChildID) { + this.firstVisibleChildID = id; + let currentNode = this.model.getNode(id); + let expansionAncestor; + let selectionAncestor; + while (currentNode && (!expansionAncestor || !selectionAncestor)) { + if (!selectionAncestor && SelectableTreeNode.is(currentNode)) { + selectionAncestor = currentNode; + } + if (!expansionAncestor && ExpandableTreeNode.is(currentNode)) { + expansionAncestor = currentNode; + } + currentNode = currentNode.parent; + } + if (selectionAncestor) { + this.currentModelSelectionId = selectionAncestor.id; + expansionAncestor = expansionAncestor ?? selectionAncestor; + this.model.selectIfNotSelected(selectionAncestor); + if (!this.model.isFiltered && id !== this.lastUserSelection) { + this.lastUserSelection = ''; + this.model.collapseAllExcept(expansionAncestor); + } + } + } + } + + protected handleSelectionChange(selectionEvent: readonly Readonly[]): void { + const node = selectionEvent[0]; + if (node && node.id !== this.currentModelSelectionId) { + this.currentModelSelectionId = node.id; + this.lastUserSelection = node.id; + if (this.model.isFiltered && CompositeTreeNode.is(node)) { + for (const candidate of new TopDownTreeIterator(node, { pruneSiblings: true })) { + const { id, collection } = this.analyzeIDAndGetRendererGroup(candidate.id); + const renderer = collection.get(id); + if (renderer?.visible) { + // When filtered, treat the first visible child as the selected node, since it will be the one scrolled to. + this.lastUserSelection = renderer.nodeId; + renderer.node.scrollIntoView(); + return; + } + } + } else { + const { id, collection } = this.analyzeIDAndGetRendererGroup(node.id); + const renderer = collection.get(id); + renderer?.node.scrollIntoView(); + } + } + } + + protected analyzeIDAndGetRendererGroup(nodeID: string): { id: string, group: string, collection: Map } { + const { id, group } = Preference.TreeNode.getGroupAndIdFromNodeId(nodeID); + const collection = group === COMMONLY_USED_SECTION_PREFIX ? this.commonlyUsedRenderers : this.renderers; + return { id, group, collection }; + } + + protected getScrollContainer(): HTMLElement { + return this.scrollContainer; + } + + storeState(): PreferencesEditorState { + return { + firstVisibleChildID: this.firstVisibleChildID, + }; + } + + restoreState(oldState: PreferencesEditorState): void { + this.firstVisibleChildID = oldState.firstVisibleChildID; + this.resetScroll(this.firstVisibleChildID); + } +} diff --git a/packages/preferences/src/browser/views/preference-editor-widget.tsx b/packages/preferences/src/browser/views/preference-editor-widget.tsx deleted file mode 100644 index 2b1ff8c80e934..0000000000000 --- a/packages/preferences/src/browser/views/preference-editor-widget.tsx +++ /dev/null @@ -1,242 +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 - ********************************************************************************/ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { postConstruct, injectable, inject } from '@theia/core/shared/inversify'; -import * as React from '@theia/core/shared/react'; -import debounce = require('@theia/core/shared/lodash.debounce'); -import { Disposable } from '@theia/core/shared/vscode-ws-jsonrpc'; -import { - ReactWidget, - PreferenceService, - CompositeTreeNode, - SelectableTreeNode, - PreferenceItem, - TreeNode, - ExpandableTreeNode, - StatefulWidget, -} from '@theia/core/lib/browser'; -import { Message, } from '@theia/core/lib/browser/widgets/widget'; -import { SinglePreferenceDisplayFactory } from './components/single-preference-display-factory'; -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'; - -export interface PreferencesEditorState { - firstVisibleChildID: string, -} - -@injectable() -export class PreferencesEditorWidget extends ReactWidget implements StatefulWidget { - static readonly ID = 'settings.editor'; - static readonly LABEL = 'Settings Editor'; - - 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 shouldScroll: boolean = true; - protected lastUserSelection: string = ''; - protected isAtScrollTop: boolean = true; - protected firstVisibleChildID: string = ''; - - @inject(PreferenceService) protected readonly preferenceValueRetrievalService: PreferenceService; - @inject(PreferenceTreeModel) protected readonly model: PreferenceTreeModel; - @inject(SinglePreferenceDisplayFactory) protected readonly singlePreferenceFactory: SinglePreferenceDisplayFactory; - - @postConstruct() - protected init(): void { - this.onRender.push(Disposable.create(() => this.hasRendered = true)); - this.id = PreferencesEditorWidget.ID; - this.title.label = PreferencesEditorWidget.LABEL; - this.preferenceValueRetrievalService.onPreferenceChanged((): void => { - this.update(); - }); - this.model.onFilterChanged(({ filterCleared }) => this.handleDisplayChange(filterCleared)); - this.model.onSelectionChanged(e => this.handleSelectionChange(e)); - this.update(); - } - - protected callAfterFirstRender(callback: Function): void { - if (this.hasRendered) { - callback(); - } else { - this.onRender.push(Disposable.create(() => callback())); - } - } - - protected onAfterAttach(msg: Message): void { - this.callAfterFirstRender(() => { - super.onAfterAttach(msg); - this.node.addEventListener('scroll', this.onScroll); - }); - } - - protected render(): React.ReactNode { - const visibleNodes = Array.from(this.model.currentRows.values()); - return ( -
    -
    - {!this.model.totalVisibleLeaves ? this.renderNoResultMessage() : visibleNodes.map(nodeRow => { - if (!CompositeTreeNode.is(nodeRow.node)) { - return this.renderSingleEntry(nodeRow.node); - } else { - return this.renderCategoryHeader(nodeRow); - } - })} -
    -
    - ); - } - - 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; - } - }; - - protected doOnScroll = (): void => { - const scrollContainer = this.node; - 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 = ''; - }; - - 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 }; - } - } - } - } - - protected isInView(e: HTMLElement, parent: HTMLElement): boolean { - const scrollTop = this.node.scrollTop; - const scrollCheckHeight = 0.7; - return this.compare(e.offsetTop).isBetween(scrollTop, scrollTop + parent.offsetHeight) || - this.compare(scrollTop).isBetween(e.offsetTop, e.offsetTop + (e.offsetHeight * scrollCheckHeight)); - } - - protected compare = (value: number): { isBetween: (a: number, b: number) => boolean; } => ({ - isBetween: (a: number, b: number): boolean => ( - (value >= a && value <= b) || (value >= b && value <= a) - ) - }); - - protected renderSingleEntry(node: TreeNode): React.ReactNode { - const values = this.preferenceValueRetrievalService.inspect(node.id, this.model.currentScope.uri); - const data = this.model.propertyList[node.id]; - if (data && values) { - const preferenceNodeWithValueInAllScopes = { ...node, preference: { data, values } }; - return this.singlePreferenceFactory.render(preferenceNodeWithValueInAllScopes); - } - return undefined; - } - - 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 && ( -
      -
    • {node.name}
    • -
    - ); - } - - protected renderNoResultMessage(): React.ReactNode { - return
    That search query has returned no results.
    ; - } - - 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()); - } - } - } - } - - storeState(): PreferencesEditorState { - return { - firstVisibleChildID: this.firstVisibleChildID, - }; - } - - restoreState(oldState: PreferencesEditorState): void { - this.firstVisibleChildID = oldState.firstVisibleChildID; - this.handleDisplayChange(); - } - -} 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 2538dc7545dd4..fee9f6abe18cf 100644 --- a/packages/preferences/src/browser/views/preference-scope-tabbar-widget.tsx +++ b/packages/preferences/src/browser/views/preference-scope-tabbar-widget.tsx @@ -25,11 +25,11 @@ 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(); +const USER_TAB_INDEX = PreferenceScope[USER_TAB_LABEL]; const WORKSPACE_TAB_LABEL = 'Workspace'; -const WORKSPACE_TAB_INDEX = PreferenceScope[WORKSPACE_TAB_LABEL].toString(); +const WORKSPACE_TAB_INDEX = PreferenceScope[WORKSPACE_TAB_LABEL]; const FOLDER_TAB_LABEL = 'Folder'; -const FOLDER_TAB_INDEX = PreferenceScope[FOLDER_TAB_LABEL].toString(); +const FOLDER_TAB_INDEX = PreferenceScope[FOLDER_TAB_LABEL]; const PREFERENCE_TAB_CLASSNAME = 'preferences-scope-tab'; const GENERAL_FOLDER_TAB_CLASSNAME = 'preference-folder'; @@ -40,6 +40,7 @@ const TABBAR_UNDERLINE_CLASSNAME = 'tabbar-underline'; 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}`; +const SHADOW_CLASSNAME = 'with-shadow'; export interface PreferencesScopeTabBarState { scopeDetails: Preference.SelectedScopeDetails; @@ -67,12 +68,12 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi } protected setNewScopeSelection(newSelection: Preference.SelectedScopeDetails): void { - - const newIndex = this.titles.findIndex(title => title.dataset.scope === newSelection.scope); + const stringifiedSelectionScope = newSelection.scope.toString(); + const newIndex = this.titles.findIndex(title => title.dataset.scope === stringifiedSelectionScope); if (newIndex !== -1) { this.currentSelection = newSelection; this.currentIndex = newIndex; - if (newSelection.scope === PreferenceScope.Folder.toString()) { + if (newSelection.scope === PreferenceScope.Folder) { this.addOrUpdateFolderTab(); } this.emitNewScope(); @@ -84,8 +85,9 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi this.id = PreferencesScopeTabBar.ID; this.setupInitialDisplay(); this.tabActivateRequested.connect((sender, args) => { - if (!!args.title) { - this.setNewScopeSelection(args.title.dataset as unknown as Preference.SelectedScopeDetails); + const scopeDetails = this.toScopeDetails(args.title); + if (scopeDetails) { + this.setNewScopeSelection(scopeDetails); } }); this.workspaceService.onWorkspaceChanged(newRoots => { @@ -97,6 +99,27 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi this.node.append(tabUnderline); } + protected toScopeDetails(title?: Title | Preference.SelectedScopeDetails): Preference.SelectedScopeDetails | undefined { + if (title) { + const source = 'dataset' in title ? title.dataset : title; + const { scope, uri, activeScopeIsFolder } = source; + return { + scope: Number(scope), + uri: uri || undefined, + activeScopeIsFolder: activeScopeIsFolder === 'true' || activeScopeIsFolder === true, + }; + } + } + + protected toDataSet(scopeDetails: Preference.SelectedScopeDetails): Title.Dataset { + const { scope, uri, activeScopeIsFolder } = scopeDetails; + return { + scope: scope.toString(), + uri: uri ?? '', + activeScopeIsFolder: activeScopeIsFolder.toString() + }; + } + protected setupInitialDisplay(): void { this.addUserTab(); if (this.workspaceService.workspace) { @@ -113,20 +136,25 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi protected addTabIndexToTabs(): void { this.node.querySelectorAll('li').forEach((tab, index) => { tab.tabIndex = 0; - tab.onkeypress = () => { + const handler = () => { if (tab.className.includes(GENERAL_FOLDER_TAB_CLASSNAME) && this.currentWorkspaceRoots.length > 1) { const tabRect = tab.getBoundingClientRect(); this.openContextMenu(tabRect, tab, 'keypress'); } else { - this.setNewScopeSelection(this.titles[index].dataset as unknown as Preference.SelectedScopeDetails); + const details = this.toScopeDetails(this.titles[index]); + if (details) { + this.setNewScopeSelection(details); + } } }; + tab.onkeydown = handler; + tab.onclick = handler; }); } protected addUserTab(): void { this.addTab(new Title({ - dataset: { uri: '', scope: USER_TAB_INDEX }, + dataset: { uri: '', scope: USER_TAB_INDEX.toString() }, label: USER_TAB_LABEL, owner: this, className: PREFERENCE_TAB_CLASSNAME @@ -134,8 +162,9 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi } protected addWorkspaceTab(currentWorkspace: FileStat): Title { + const scopeDetails = this.getWorkspaceDataset(currentWorkspace); const workspaceTabTitle = new Title({ - dataset: this.getWorkspaceDataset(currentWorkspace), + dataset: this.toDataSet(scopeDetails), label: WORKSPACE_TAB_LABEL, owner: this, className: PREFERENCE_TAB_CLASSNAME, @@ -147,8 +176,7 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi 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 }; + return { uri: resource.toString(), activeScopeIsFolder: isDirectory, scope }; } protected addOrUpdateFolderTab(): void { @@ -186,7 +214,7 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi this.folderTitle.iconClass = multipleFolderRootsAreAvailable ? FOLDER_DROPDOWN_ICON_CLASSNAME : ''; if (this.currentSelection.scope === FOLDER_TAB_INDEX) { this.folderTitle.label = this.labelProvider.getName(new URI(this.currentSelection.uri)); - this.folderTitle.dataset = { ...this.currentSelection, folderTitle: 'true' }; + this.folderTitle.dataset = this.toDataSet(this.currentSelection); this.folderTitle.className = multipleFolderRootsAreAvailable ? SELECTED_FOLDER_DROPDOWN_CLASSNAME : SINGLE_FOLDER_TAB_CLASSNAME; } else { const singleFolderRoot = this.currentWorkspaceRoots[0].resource; @@ -194,7 +222,7 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi const defaultURI = multipleFolderRootsAreAvailable ? '' : singleFolderRoot.toString(); this.folderTitle.label = multipleFolderRootsAreAvailable ? FOLDER_TAB_LABEL : singleFolderLabel; this.folderTitle.className = multipleFolderRootsAreAvailable ? UNSELECTED_FOLDER_DROPDOWN_CLASSNAME : SINGLE_FOLDER_TAB_CLASSNAME; - this.folderTitle.dataset = { folderTitle: 'true', scope: FOLDER_TAB_INDEX, uri: defaultURI }; + this.folderTitle.dataset = { folderTitle: 'true', scope: FOLDER_TAB_INDEX.toString(), uri: defaultURI }; } } @@ -204,14 +232,8 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi this.preferencesMenuFactory.createFolderWorkspacesMenu(workspaceRoots, this.currentSelection.uri); } - handleEvent(e: Event): void { - const folderTab = this.contentNode.querySelector(`.${GENERAL_FOLDER_TAB_CLASSNAME}`); - if (folderTab && folderTab.contains(e.target as HTMLElement) && this.currentWorkspaceRoots.length > 1) { - const tabRect = folderTab.getBoundingClientRect(); - this.openContextMenu(tabRect, (folderTab as HTMLElement), 'click'); - return; - } - super.handleEvent(e); + handleEvent(): void { + // Don't - the handlers are defined in PreferenceScopeTabbarWidget.addTabIndexToTabs() } protected openContextMenu(tabRect: DOMRect | ClientRect, folderTabNode: HTMLElement, source: 'click' | 'keypress'): void { @@ -242,9 +264,10 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi 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); + const scopeDetails = this.getWorkspaceDataset(currentWorkspace); + workspaceTitle.dataset = this.toDataSet(scopeDetails); + if (this.currentSelection.scope === PreferenceScope.Workspace) { + this.setNewScopeSelection(scopeDetails); } } } @@ -253,6 +276,15 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi this.onScopeChangedEmitter.fire(this.currentSelection); } + setScope(scope: PreferenceScope.User | PreferenceScope.Workspace): void { + const stringifiedSelectionScope = scope.toString(); + const correspondingTitle = this.titles.find(title => title.dataset.scope === stringifiedSelectionScope); + const details = this.toScopeDetails(correspondingTitle); + if (details) { + this.setNewScopeSelection(details); + } + } + storeState(): PreferencesScopeTabBarState { return { scopeDetails: this.currentScope @@ -260,6 +292,13 @@ export class PreferencesScopeTabBar extends TabBar implements StatefulWi } restoreState(oldState: PreferencesScopeTabBarState): void { - this.setNewScopeSelection(oldState.scopeDetails); + const scopeDetails = this.toScopeDetails(oldState.scopeDetails); + if (scopeDetails) { + this.setNewScopeSelection(scopeDetails); + } + } + + toggleShadow(showShadow: boolean): void { + this.toggleClass(SHADOW_CLASSNAME, showShadow); } } diff --git a/packages/preferences/src/browser/views/preference-searchbar-widget.tsx b/packages/preferences/src/browser/views/preference-searchbar-widget.tsx index d267bf8eb6b01..2042ea78a580a 100644 --- a/packages/preferences/src/browser/views/preference-searchbar-widget.tsx +++ b/packages/preferences/src/browser/views/preference-searchbar-widget.tsx @@ -119,7 +119,7 @@ export class PreferencesSearchbarWidget extends ReactWidget implements StatefulW return search?.value; } - protected updateSearchTerm(searchTerm: string): void { + updateSearchTerm(searchTerm: string): void { const search = document.getElementById(PreferencesSearchbarWidget.SEARCHBAR_ID) as HTMLInputElement; if (!search) { return; diff --git a/packages/preferences/src/browser/views/preference-tree-widget.tsx b/packages/preferences/src/browser/views/preference-tree-widget.tsx index 185165acccbf7..d71376fc71acd 100644 --- a/packages/preferences/src/browser/views/preference-tree-widget.tsx +++ b/packages/preferences/src/browser/views/preference-tree-widget.tsx @@ -18,13 +18,11 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify' import { ContextMenuRenderer, ExpandableTreeNode, - PreferenceService, TreeNode, TreeProps, TreeWidget, TREE_NODE_CONTENT_CLASS, } from '@theia/core/lib/browser'; -import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; import React = require('@theia/core/shared/react'); import { PreferenceTreeModel, PreferenceTreeNodeRow, PreferenceTreeNodeProps } from '../preference-tree-model'; @@ -35,8 +33,6 @@ export class PreferencesTreeWidget extends TreeWidget { protected shouldFireSelectionEvents: boolean = true; protected firstVisibleLeafNodeID: string; - @inject(PreferenceService) protected readonly preferenceService: PreferenceService; - @inject(PreferenceConfigurations) protected readonly preferenceConfigs: PreferenceConfigurations; @inject(PreferenceTreeModel) readonly model: PreferenceTreeModel; @inject(TreeProps) protected readonly treeProps: TreeProps; @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; @@ -73,13 +69,10 @@ export class PreferencesTreeWidget extends TreeWidget { } const attributes = this.createNodeAttributes(node, props); - const printedNameWithVisibleChildren = node.name && this.model.isFiltered - ? `${node.name} (${props.visibleChildren})` - : node.name; const content =
    {this.renderExpansionToggle(node, props)} - {this.renderCaption({ ...node, name: printedNameWithVisibleChildren }, props)} + {this.renderCaption(node, props)}
    ; return React.createElement('div', attributes, content); } @@ -90,4 +83,13 @@ export class PreferencesTreeWidget extends TreeWidget { } return super.renderExpansionToggle(node, props); } + + protected toNodeName(node: TreeNode): string { + const visibleChildren = this.model.currentRows.get(node.id)?.visibleChildren; + const baseName = this.labelProvider.getName(node); + const printedNameWithVisibleChildren = this.model.isFiltered && visibleChildren !== undefined + ? `${baseName} (${visibleChildren})` + : baseName; + return printedNameWithVisibleChildren; + } } diff --git a/packages/preferences/src/browser/views/preference-widget-bindings.ts b/packages/preferences/src/browser/views/preference-widget-bindings.ts index 718c9ec39a3db..7f5541e3f8578 100644 --- a/packages/preferences/src/browser/views/preference-widget-bindings.ts +++ b/packages/preferences/src/browser/views/preference-widget-bindings.ts @@ -14,19 +14,26 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { interfaces, Container } from '@theia/core/shared/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 { WidgetFactory, createTreeContainer, TreeWidget, TreeProps, defaultTreeProps, TreeModel, LabelProviderContribution } from '@theia/core/lib/browser'; +import { PreferenceNodeRendererFactory, PreferenceHeaderRenderer } from './components/preference-node-renderer'; import { PreferencesWidget } from './preference-widget'; import { PreferencesTreeWidget } from './preference-tree-widget'; import { PreferencesEditorWidget } from './preference-editor-widget'; 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'; +import { PreferenceTreeLabelProvider } from '../util/preference-tree-label-provider'; +import { Preference } from '../util/preference-types'; +import { PreferenceStringInputRenderer } from './components/preference-string-input'; +import { PreferenceBooleanInputRenderer } from './components/preference-boolean-input'; +import { PreferenceJSONLinkRenderer } from './components/preference-json-input'; +import { PreferenceSelectInputRenderer } from './components/preference-select-input'; +import { PreferenceNumberInputRenderer } from './components/preference-number-input'; +import { PreferenceArrayInputRenderer } from './components/preference-array-input'; export function bindPreferencesWidgets(bind: interfaces.Bind): void { + bind(PreferenceTreeLabelProvider).toSelf().inSingletonScope(); + bind(LabelProviderContribution).toService(PreferenceTreeLabelProvider); bind(PreferencesWidget) .toDynamicValue(({ container }) => createPreferencesWidgetContainer(container).get(PreferencesWidget)) .inSingletonScope(); @@ -44,15 +51,37 @@ function createPreferencesWidgetContainer(parent: interfaces.Container): Contain child.bind(PreferencesTreeWidget).toSelf(); child.rebind(TreeProps).toConstantValue({ ...defaultTreeProps, search: false }); 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(); + child.bind(PreferenceNodeRendererFactory).toFactory(({ container }) => (node: Preference.TreeNode) => { + const grandChild = container.createChild(); + grandChild.bind(Preference.Node).toConstantValue(node); + if (Preference.LeafNode.is(node)) { + if (node.preference.data.enum) { + return grandChild.resolve(PreferenceSelectInputRenderer); + } + const type = Array.isArray(node.preference.data.type) ? node.preference.data.type[0] : node.preference.data.type; + if (type === 'array' && node.preference.data.items?.type === 'string') { + return grandChild.resolve(PreferenceArrayInputRenderer); + } + switch (type) { + case 'string': + return grandChild.resolve(PreferenceStringInputRenderer); + case 'boolean': + return grandChild.resolve(PreferenceBooleanInputRenderer); + case 'number': + case 'integer': + return grandChild.resolve(PreferenceNumberInputRenderer); + default: + return grandChild.resolve(PreferenceJSONLinkRenderer); + } + } else { + return grandChild.resolve(PreferenceHeaderRenderer); + } + }); + return child; } diff --git a/packages/preferences/src/browser/views/preference-widget.tsx b/packages/preferences/src/browser/views/preference-widget.tsx index 5d3994d1688d2..13e29984ebbc3 100644 --- a/packages/preferences/src/browser/views/preference-widget.tsx +++ b/packages/preferences/src/browser/views/preference-widget.tsx @@ -22,8 +22,6 @@ import { PreferencesSearchbarState, PreferencesSearchbarWidget } from './prefere import { PreferencesScopeTabBar, PreferencesScopeTabBarState } from './preference-scope-tabbar-widget'; import { Preference } from '../util/preference-types'; -const SHADOW_CLASSNAME = 'with-shadow'; - interface PreferencesWidgetState { scopeTabBarState: PreferencesScopeTabBarState, editorState: PreferencesEditorState, @@ -85,13 +83,6 @@ export class PreferencesWidget extends Panel implements StatefulWidget { 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(); } diff --git a/packages/vsx-registry/src/browser/vsx-extensions-model.ts b/packages/vsx-registry/src/browser/vsx-extensions-model.ts index baa5f1c993900..d5f16ce5a89b6 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-model.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-model.ts @@ -243,7 +243,7 @@ export class VSXExtensionsModel { } protected getRecommendationsForScope(scope: PreferenceInspectionScope, root?: URI): Required { - const configuredValue = this.preferences.inspect('extensions', root?.toString())?.[scope]; + const configuredValue = this.preferences.inspect>('extensions', root?.toString())?.[scope]; return { recommendations: configuredValue?.recommendations ?? [], unwantedRecommendations: configuredValue?.unwantedRecommendations ?? [], From 0ab4907080e50493822c82343446d1ae0bce23fb Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 10 Jun 2021 11:36:55 -0500 Subject: [PATCH 2/2] PM comments Signed-off-by: Colin Grant --- .../browser/preferences/preference-service.ts | 4 +-- packages/core/src/common/types.ts | 7 ++++++ .../browser/views/preference-editor-widget.ts | 3 ++- .../views/preference-widget-bindings.ts | 25 ++++++++++++------- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/core/src/browser/preferences/preference-service.ts b/packages/core/src/browser/preferences/preference-service.ts index cf6411988997b..df9929f1275c2 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -17,7 +17,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { injectable, inject, postConstruct } from 'inversify'; -import { Event, Emitter, DisposableCollection, Disposable, deepFreeze } from '../../common'; +import { Event, Emitter, DisposableCollection, Disposable, deepFreeze, unreachable } from '../../common'; import { Deferred } from '../../common/promise-util'; import { PreferenceProvider, PreferenceProviderDataChange, PreferenceProviderDataChanges, PreferenceResolveResult } from './preference-provider'; import { PreferenceSchemaProvider } from './preference-contribution'; @@ -504,7 +504,7 @@ export class PreferenceServiceImpl implements PreferenceService { case PreferenceScope.Folder: return inspection.workspaceFolderValue; } - return ((unhandledScope: never): never => { throw new Error('Must handle all enum values!'); })(scope); + unreachable(scope, 'Not all PreferenceScope enum variants handled.'); } async updateValue(preferenceName: string, value: any, resourceUri?: string): Promise { diff --git a/packages/core/src/common/types.ts b/packages/core/src/common/types.ts index 0b3d75ec3502c..7e4feeb87b636 100644 --- a/packages/core/src/common/types.ts +++ b/packages/core/src/common/types.ts @@ -67,3 +67,10 @@ export namespace Prioritizeable { return p2.priority - p.priority; } } + +/** + * Throws when called and statically make sure that all variants of a type were consumed. + */ +export function unreachable(_never: never, message: string = 'unhandled case'): never { + throw new Error(message); +} diff --git a/packages/preferences/src/browser/views/preference-editor-widget.ts b/packages/preferences/src/browser/views/preference-editor-widget.ts index 8b3132b3457e7..ec5f5e89b9cd3 100644 --- a/packages/preferences/src/browser/views/preference-editor-widget.ts +++ b/packages/preferences/src/browser/views/preference-editor-widget.ts @@ -26,6 +26,7 @@ import { PreferenceChanges, ExpandableTreeNode, } from '@theia/core/lib/browser'; +import { unreachable } from '@theia/core/lib/common'; import { BaseWidget, DEFAULT_SCROLL_OPTIONS, } from '@theia/core/lib/browser/widgets/widget'; import { PreferenceTreeModel, PreferenceFilterChangeEvent, PreferenceFilterChangeSource } from '../preference-tree-model'; import { PreferenceNodeRendererFactory, GeneralPreferenceNodeRenderer } from './components/preference-node-renderer'; @@ -99,7 +100,7 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge } else if (e.source === PreferenceFilterChangeSource.Schema) { this.handleSchemaChange(isFiltered); } else { - ((otherSource: never) => { throw new Error('You should handle all possible sources of change!'); })(e.source); + unreachable(e.source, 'Not all PreferenceFilterChangeSource enum variants handled.'); } this.resetScroll(currentFirstVisible, e.source === PreferenceFilterChangeSource.Search && !isFiltered); } diff --git a/packages/preferences/src/browser/views/preference-widget-bindings.ts b/packages/preferences/src/browser/views/preference-widget-bindings.ts index 7f5541e3f8578..51cf95a304912 100644 --- a/packages/preferences/src/browser/views/preference-widget-bindings.ts +++ b/packages/preferences/src/browser/views/preference-widget-bindings.ts @@ -41,6 +41,13 @@ export function bindPreferencesWidgets(bind: interfaces.Bind): void { id: PreferencesWidget.ID, createWidget: () => container.get(PreferencesWidget) })).inSingletonScope(); + bind(PreferenceSelectInputRenderer).toSelf(); + bind(PreferenceArrayInputRenderer).toSelf(); + bind(PreferenceStringInputRenderer).toSelf(); + bind(PreferenceBooleanInputRenderer).toSelf(); + bind(PreferenceNumberInputRenderer).toSelf(); + bind(PreferenceJSONLinkRenderer).toSelf(); + bind(PreferenceHeaderRenderer).toSelf(); } function createPreferencesWidgetContainer(parent: interfaces.Container): Container { @@ -57,29 +64,29 @@ function createPreferencesWidgetContainer(parent: interfaces.Container): Contain child.bind(PreferencesWidget).toSelf(); child.bind(PreferenceNodeRendererFactory).toFactory(({ container }) => (node: Preference.TreeNode) => { - const grandChild = container.createChild(); - grandChild.bind(Preference.Node).toConstantValue(node); + const grandchild = container.createChild(); + grandchild.bind(Preference.Node).toConstantValue(node); if (Preference.LeafNode.is(node)) { if (node.preference.data.enum) { - return grandChild.resolve(PreferenceSelectInputRenderer); + return grandchild.get(PreferenceSelectInputRenderer); } const type = Array.isArray(node.preference.data.type) ? node.preference.data.type[0] : node.preference.data.type; if (type === 'array' && node.preference.data.items?.type === 'string') { - return grandChild.resolve(PreferenceArrayInputRenderer); + return grandchild.get(PreferenceArrayInputRenderer); } switch (type) { case 'string': - return grandChild.resolve(PreferenceStringInputRenderer); + return grandchild.get(PreferenceStringInputRenderer); case 'boolean': - return grandChild.resolve(PreferenceBooleanInputRenderer); + return grandchild.get(PreferenceBooleanInputRenderer); case 'number': case 'integer': - return grandChild.resolve(PreferenceNumberInputRenderer); + return grandchild.get(PreferenceNumberInputRenderer); default: - return grandChild.resolve(PreferenceJSONLinkRenderer); + return grandchild.get(PreferenceJSONLinkRenderer); } } else { - return grandChild.resolve(PreferenceHeaderRenderer); + return grandchild.get(PreferenceHeaderRenderer); } });