deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
// *****************************************************************************
// Copyright (C) 2019 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { SectionPreferenceProvider } from '../common/section-preference-provider';
import { PreferenceScope } from '@theia/core';
import { WorkspaceService } from '@theia/workspace/lib/browser';
export const FolderPreferenceProviderFactory = Symbol('FolderPreferenceProviderFactory');
export interface FolderPreferenceProviderFactory {
(uri: URI, section: string, folder: FileStat): FolderPreferenceProvider;
}
export const FolderPreferenceProviderFolder = Symbol('FolderPreferenceProviderFolder');
export interface FolderPreferenceProviderOptions {
readonly configUri: URI;
readonly sectionName: string | undefined;
}
@injectable()
export class FolderPreferenceProvider extends SectionPreferenceProvider {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(FolderPreferenceProviderFolder) protected readonly folder: FileStat;
private _folderUri: URI;
get folderUri(): URI {
if (!this._folderUri) {
this._folderUri = this.folder.resource;
}
return this._folderUri;
}
getScope(): PreferenceScope {
if (!this.workspaceService.isMultiRootWorkspaceOpened) {
// when FolderPreferenceProvider is used as a delegate of WorkspacePreferenceProvider in a one-folder workspace
return PreferenceScope.Workspace;
}
return PreferenceScope.Folder;
}
override getDomain(): string[] {
return [this.folderUri.toString()];
}
}

View File

@@ -0,0 +1,243 @@
// *****************************************************************************
// Copyright (C) 2019 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-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { FolderPreferenceProvider, FolderPreferenceProviderFactory } from './folder-preference-provider';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { PreferenceProviderImpl, PreferenceConfigurations, PreferenceResolveResult, PreferenceScope, PreferenceUtils } from '@theia/core';
@injectable()
export class FoldersPreferencesProvider extends PreferenceProviderImpl {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(FolderPreferenceProviderFactory)
protected readonly folderPreferenceProviderFactory: FolderPreferenceProviderFactory;
@inject(PreferenceConfigurations)
protected readonly configurations: PreferenceConfigurations;
protected readonly providers = new Map<string, FolderPreferenceProvider>();
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
await this.workspaceService.roots;
this.updateProviders();
this.workspaceService.onWorkspaceChanged(() => this.updateProviders());
const readyPromises: Promise<void>[] = [];
for (const provider of this.providers.values()) {
readyPromises.push(provider.ready.catch(e => console.error(e)));
}
Promise.all(readyPromises).then(() => this._ready.resolve());
}
protected updateProviders(): void {
const roots = this.workspaceService.tryGetRoots();
const toDelete = new Set(this.providers.keys());
for (const folder of roots) {
for (const configPath of this.configurations.getPaths()) {
for (const configName of [...this.configurations.getSectionNames(), this.configurations.getConfigName()]) {
const sectionUri = this.configurations.createUri(folder.resource, configPath, configName);
const sectionKey = sectionUri.toString();
toDelete.delete(sectionKey);
if (!this.providers.has(sectionKey)) {
const provider = this.createProvider(sectionUri, configName, folder);
this.providers.set(sectionKey, provider);
}
}
}
}
for (const key of toDelete) {
const provider = this.providers.get(key);
if (provider) {
this.providers.delete(key);
provider.dispose();
}
}
}
override getConfigUri(resourceUri?: string, sectionName: string = this.configurations.getConfigName()): URI | undefined {
for (const provider of this.getFolderProviders(resourceUri)) {
const configUri = provider.getConfigUri(resourceUri);
if (configUri && this.configurations.getName(configUri) === sectionName) {
return configUri;
}
}
return undefined;
}
override getContainingConfigUri(resourceUri?: string, sectionName: string = this.configurations.getConfigName()): URI | undefined {
for (const provider of this.getFolderProviders(resourceUri)) {
const configUri = provider.getConfigUri();
if (provider.contains(resourceUri) && this.configurations.getName(configUri) === sectionName) {
return configUri;
}
}
return undefined;
}
override getDomain(): string[] {
return this.workspaceService.tryGetRoots().map(root => root.resource.toString());
}
override resolve<T>(preferenceName: string, resourceUri?: string): PreferenceResolveResult<T> {
const result: PreferenceResolveResult<T> = {};
const groups = this.groupProvidersByConfigName(resourceUri);
for (const group of groups.values()) {
for (const provider of group) {
const { value, configUri } = provider.resolve<T>(preferenceName, resourceUri);
if (configUri && value !== undefined) {
result.configUri = configUri;
result.value = PreferenceUtils.merge(result.value as any, value as any) as any;
break;
}
}
}
return result;
}
getPreferences(resourceUri?: string): { [p: string]: any } {
let result = {};
const groups = this.groupProvidersByConfigName(resourceUri);
for (const group of groups.values()) {
for (const provider of group) {
if (provider.getConfigUri(resourceUri)) {
const preferences = provider.getPreferences();
result = PreferenceUtils.merge(result, preferences) as any;
break;
}
}
}
return result;
}
async setPreference(preferenceName: string, value: any, resourceUri?: string): Promise<boolean> {
const firstPathFragment = preferenceName.split('.', 1)[0];
const defaultConfigName = this.configurations.getConfigName();
const configName = this.configurations.isSectionName(firstPathFragment) ? firstPathFragment : defaultConfigName;
const providers = this.getFolderProviders(resourceUri);
let configPath: string | undefined;
const candidates = providers.filter(provider => {
// Attempt to figure out the settings folder (.vscode or .theia) we're interested in.
const containingConfigUri = provider.getConfigUri(resourceUri);
if (configPath === undefined && containingConfigUri) {
configPath = this.configurations.getPath(containingConfigUri);
}
const providerName = this.configurations.getName(containingConfigUri ?? provider.getConfigUri());
return providerName === configName || providerName === defaultConfigName;
});
const configNameAndPathMatches = [];
const configNameOnlyMatches = [];
const configUriMatches = [];
const otherMatches = [];
for (const candidate of candidates) {
const domainMatches = candidate.getConfigUri(resourceUri);
const configUri = domainMatches ?? candidate.getConfigUri();
const nameMatches = this.configurations.getName(configUri) === configName;
const pathMatches = this.configurations.getPath(configUri) === configPath;
// Perfect match, run immediately in case we can bail out early.
if (nameMatches && domainMatches) {
if (await candidate.setPreference(preferenceName, value, resourceUri)) {
return true;
}
} else if (nameMatches && pathMatches) { // Right file in the right folder.
configNameAndPathMatches.push(candidate);
} else if (nameMatches) { // Right file.
configNameOnlyMatches.push(candidate);
} else if (domainMatches) { // Currently valid and governs target URI
configUriMatches.push(candidate);
} else {
otherMatches.push(candidate);
}
}
const candidateSets = [configNameAndPathMatches, configNameOnlyMatches, configUriMatches, otherMatches];
for (const candidateSet of candidateSets) {
for (const candidate of candidateSet) {
if (await candidate.setPreference(preferenceName, value, resourceUri)) {
return true;
}
}
}
return false;
}
override canHandleScope(scope: PreferenceScope): boolean {
return this.workspaceService.isMultiRootWorkspaceOpened && scope === PreferenceScope.Folder || scope === PreferenceScope.Workspace;
}
protected groupProvidersByConfigName(resourceUri?: string): Map<string, FolderPreferenceProvider[]> {
const groups = new Map<string, FolderPreferenceProvider[]>();
const providers = this.getFolderProviders(resourceUri);
for (const configName of [this.configurations.getConfigName(), ...this.configurations.getSectionNames()]) {
const group = [];
for (const provider of providers) {
if (this.configurations.getName(provider.getConfigUri()) === configName) {
group.push(provider);
}
}
groups.set(configName, group);
}
return groups;
}
protected getFolderProviders(resourceUri?: string): FolderPreferenceProvider[] {
if (!resourceUri) {
return [];
}
const resourcePath = new URI(resourceUri).path;
let folder: Readonly<{ relativity: number, uri?: string }> = { relativity: Number.MAX_SAFE_INTEGER };
const providers = new Map<string, FolderPreferenceProvider[]>();
for (const provider of this.providers.values()) {
const uri = provider.folderUri.toString();
const folderProviders = (providers.get(uri) || []);
folderProviders.push(provider);
providers.set(uri, folderProviders);
// in case we have nested folders mounted as workspace roots, select the innermost enclosing folder
const relativity = provider.folderUri.path.relativity(resourcePath);
if (relativity >= 0 && folder.relativity > relativity) {
folder = { relativity, uri };
}
}
return folder.uri && providers.get(folder.uri) || [];
}
protected createProvider(uri: URI, section: string, folder: FileStat): FolderPreferenceProvider {
const provider = this.folderPreferenceProviderFactory(uri, section, folder);
this.toDispose.push(provider);
this.toDispose.push(provider.onDidPreferencesChanged(change => this.onDidPreferencesChangedEmitter.fire(change)));
return provider;
}
}

View File

@@ -0,0 +1,72 @@
// *****************************************************************************
// Copyright (C) 2025 STMicroelectronics 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ListenerList, DisposableCollection, URI, PreferenceScope, Listener } from '@theia/core';
import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileContentStatus, PreferenceStorage } from '../common/abstract-resource-preference-provider';
import { PreferenceTransaction, PreferenceTransactionFactory } from './preference-transaction-manager';
export class FrontendPreferenceStorage implements PreferenceStorage {
protected readonly onDidChangeFileContentListeners = new ListenerList<FileContentStatus, Promise<boolean>>();
protected transaction: PreferenceTransaction | undefined;
protected readonly toDispose = new DisposableCollection();
constructor(
protected readonly transactionFactory: PreferenceTransactionFactory,
protected readonly fileService: FileService,
protected readonly uri: URI,
protected readonly scope: PreferenceScope
) {
this.fileService.watch(uri);
this.fileService.onDidFilesChange(e => {
if (e.contains(uri)) {
this.read().then(content => this.onDidChangeFileContentListeners.invoke({ content, fileOK: true }, () => { }))
.catch(() => this.onDidChangeFileContentListeners.invoke({ content: '', fileOK: false }, () => { }));
}
});
}
dispose(): void {
this.toDispose.dispose();
}
writeValue(key: string, path: string[], value: JSONValue): Promise<boolean> {
if (!this.transaction?.open) {
const current = this.transaction;
this.transaction = this.transactionFactory({
getScope: () => this.scope,
getConfigUri: () => this.uri
}, current?.result);
this.transaction.onWillConclude(async status => {
if (status) {
const content = await this.read();
await Listener.awaitAll({ content, fileOK: true }, this.onDidChangeFileContentListeners);
}
});
this.toDispose.push(this.transaction);
}
return this.transaction.enqueueAction(key, path, value);
}
onDidChangeFileContent: Listener.Registration<FileContentStatus, Promise<boolean>> = this.onDidChangeFileContentListeners.registration;
async read(): Promise<string> {
return (await this.fileService.read(this.uri)).value;
}
}

View File

@@ -0,0 +1,20 @@
// *****************************************************************************
// Copyright (C) 2018 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-only WITH Classpath-exception-2.0
// *****************************************************************************
export * from '@theia/core/lib/browser/preferences';
export * from './workspace-preference-provider';
export * from './folders-preferences-provider';
export * from './folder-preference-provider';

View File

@@ -0,0 +1,67 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as jsoncparser from 'jsonc-parser';
import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as monaco from '@theia/monaco-editor-core';
@injectable()
export class MonacoJSONCEditor {
@inject(MonacoWorkspace) protected readonly workspace: MonacoWorkspace;
async setValue(model: MonacoEditorModel, path: jsoncparser.JSONPath, value: unknown, shouldSave = true): Promise<void> {
const edits = this.getEditOperations(model, path, value);
if (edits.length > 0) {
await this.workspace.applyBackgroundEdit(model, edits, shouldSave);
}
}
getEditOperations(model: MonacoEditorModel, path: jsoncparser.JSONPath, value: unknown): monaco.editor.IIdentifiedSingleEditOperation[] {
const textModel = model.textEditorModel;
const content = model.getText();
// Everything is already undefined - no need for changes.
if (!content.trim() && value === undefined) {
return [];
}
// Delete the entire document.
if (!path.length && value === undefined) {
return [{
range: textModel.getFullModelRange(),
text: null, // eslint-disable-line no-null/no-null
forceMoveMarkers: false
}];
}
const { insertSpaces, tabSize, defaultEOL } = textModel.getOptions();
const jsonCOptions = {
formattingOptions: {
insertSpaces,
tabSize,
eol: defaultEOL === monaco.editor.DefaultEndOfLine.LF ? '\n' : '\r\n'
}
};
return jsoncparser.modify(content, path, value, jsonCOptions).map(edit => {
const start = textModel.getPositionAt(edit.offset);
const end = textModel.getPositionAt(edit.offset + edit.length);
return {
range: monaco.Range.fromPositions(start, end),
text: edit.content || null, // eslint-disable-line no-null/no-null
forceMoveMarkers: false
};
});
}
}

View File

@@ -0,0 +1,28 @@
// *****************************************************************************
// Copyright (C) 2018 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-only WITH Classpath-exception-2.0
// *****************************************************************************
/* note: this bogus test file is required so that
we are able to run mocha unit tests on this
package, without having any actual unit tests in it.
This way a coverage report will be generated,
showing 0% coverage, instead of no report.
This file can be removed once we have real unit
tests in place. */
describe('preferences package', () => {
it('should support code coverage statistics', () => true);
});

View File

@@ -0,0 +1,46 @@
// *****************************************************************************
// Copyright (C) 2025 STMicroelectronics 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Container, interfaces } from '@theia/core/shared/inversify';
import { UserPreferenceProvider, UserPreferenceProviderFactory } from '../common/user-preference-provider';
import { WorkspacePreferenceProvider } from './workspace-preference-provider';
import { WorkspaceFilePreferenceProvider, WorkspaceFilePreferenceProviderFactory, WorkspaceFilePreferenceProviderOptions } from './workspace-file-preference-provider';
import { FoldersPreferencesProvider } from './folders-preferences-provider';
import { FolderPreferenceProvider, FolderPreferenceProviderFactory, FolderPreferenceProviderFolder } from './folder-preference-provider';
import { SectionPreferenceProviderUri, SectionPreferenceProviderSection } from '../common/section-preference-provider';
import { bindFactory, PreferenceProvider, PreferenceScope } from '@theia/core';
import { UserStorageUri } from '@theia/userstorage/lib/browser';
import { UserConfigsPreferenceProvider, UserStorageLocationProvider } from '../common/user-configs-preference-provider';
export function bindWorkspaceFilePreferenceProvider(bind: interfaces.Bind): void {
bind(WorkspaceFilePreferenceProviderFactory).toFactory(ctx => (options: WorkspaceFilePreferenceProviderOptions) => {
const child = new Container({ defaultScope: 'Singleton' });
child.parent = ctx.container;
child.bind(WorkspaceFilePreferenceProvider).toSelf();
child.bind(WorkspaceFilePreferenceProviderOptions).toConstantValue(options);
return child.get(WorkspaceFilePreferenceProvider);
});
}
export function bindPreferenceProviders(bind: interfaces.Bind, unbind: interfaces.Unbind): void {
bind(PreferenceProvider).to(UserConfigsPreferenceProvider).inSingletonScope().whenTargetNamed(PreferenceScope.User);
bind(PreferenceProvider).to(WorkspacePreferenceProvider).inSingletonScope().whenTargetNamed(PreferenceScope.Workspace);
bind(PreferenceProvider).to(FoldersPreferencesProvider).inSingletonScope().whenTargetNamed(PreferenceScope.Folder);
bindWorkspaceFilePreferenceProvider(bind);
bind(UserStorageLocationProvider).toConstantValue(() => UserStorageUri);
bindFactory(bind, UserPreferenceProviderFactory, UserPreferenceProvider, SectionPreferenceProviderUri, SectionPreferenceProviderSection);
bindFactory(bind, FolderPreferenceProviderFactory, FolderPreferenceProvider, SectionPreferenceProviderUri, SectionPreferenceProviderSection, FolderPreferenceProviderFolder);
}

View File

@@ -0,0 +1,38 @@
// *****************************************************************************
// Copyright (C) 2024 Typefox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { CliPreferences } from '../common/cli-preferences';
import { PreferenceService, PreferenceScope } from '@theia/core/lib/common/preferences';
@injectable()
export class PreferenceFrontendContribution implements FrontendApplicationContribution {
@inject(CliPreferences)
protected readonly CliPreferences: CliPreferences;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
onStart(): void {
this.CliPreferences.getPreferences().then(async preferences => {
await this.preferenceService.ready;
for (const [key, value] of preferences) {
this.preferenceService.set(key, value, PreferenceScope.User);
}
});
}
}

View File

@@ -0,0 +1,79 @@
// *****************************************************************************
// Copyright (C) 2018 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import '../../src/browser/style/index.css';
import './preferences-monaco-contribution';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { bindViewContribution, FrontendApplicationContribution, noopWidgetStatusBarContribution, OpenHandler, WidgetStatusBarContribution } from '@theia/core/lib/browser';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { PreferenceTreeGenerator } from './util/preference-tree-generator';
import { bindPreferenceProviders } from './preference-bindings';
import { bindPreferencesWidgets } from './views/preference-widget-bindings';
import { PreferencesContribution } from './preferences-contribution';
import { PreferenceScopeCommandManager } from './util/preference-scope-command-manager';
import { JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-store';
import { PreferencesJsonSchemaContribution } from './preferences-json-schema-contribution';
import { MonacoJSONCEditor } from './monaco-jsonc-editor';
import { PreferenceTransaction, PreferenceTransactionFactory, preferenceTransactionFactoryCreator } from './preference-transaction-manager';
import { PreferenceOpenHandler } from './preference-open-handler';
import { CliPreferences, CliPreferencesPath } from '../common/cli-preferences';
import { ServiceConnectionProvider } from '@theia/core/lib/browser/messaging/service-connection-provider';
import { PreferenceFrontendContribution } from './preference-frontend-contribution';
import { PreferenceLayoutProvider } from './util/preference-layout';
import { PreferencesWidget } from './views/preference-widget';
import { PreferenceStorageFactory } from '../common/abstract-resource-preference-provider';
import { FrontendPreferenceStorage } from './frontend-preference-storage';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { PreferenceScope, URI } from '@theia/core';
export function bindPreferences(bind: interfaces.Bind, unbind: interfaces.Unbind): void {
bindPreferenceProviders(bind, unbind);
bindPreferencesWidgets(bind);
bind(PreferenceTreeGenerator).toSelf().inSingletonScope();
bind(PreferenceLayoutProvider).toSelf().inSingletonScope();
bindViewContribution(bind, PreferencesContribution);
bind(PreferenceOpenHandler).toSelf().inSingletonScope();
bind(OpenHandler).toService(PreferenceOpenHandler);
bind(PreferenceScopeCommandManager).toSelf().inSingletonScope();
bind(TabBarToolbarContribution).toService(PreferencesContribution);
bind(PreferencesJsonSchemaContribution).toSelf().inSingletonScope();
bind(JsonSchemaContribution).toService(PreferencesJsonSchemaContribution);
bind(MonacoJSONCEditor).toSelf().inSingletonScope();
bind(PreferenceTransaction).toSelf();
bind(PreferenceTransactionFactory).toFactory(preferenceTransactionFactoryCreator);
bind(PreferenceStorageFactory).toFactory(({ container }) => (uri: URI, scope: PreferenceScope) => new FrontendPreferenceStorage(
container.get(PreferenceTransactionFactory),
container.get(FileService),
uri,
scope
));
bind(CliPreferences).toDynamicValue(ctx => ServiceConnectionProvider.createProxy<CliPreferences>(ctx.container, CliPreferencesPath)).inSingletonScope();
bind(PreferenceFrontendContribution).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(PreferenceFrontendContribution);
bind(WidgetStatusBarContribution).toConstantValue(noopWidgetStatusBarContribution(PreferencesWidget));
}
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bindPreferences(bind, unbind);
});

View File

@@ -0,0 +1,53 @@
// *****************************************************************************
// Copyright (C) 2022 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { animationFrame, OpenHandler } from '@theia/core/lib/browser';
import URI from '@theia/core/lib/common/uri';
import { injectable, inject } from '@theia/core/shared/inversify';
import { PreferencesContribution } from './preferences-contribution';
@injectable()
export class PreferenceOpenHandler implements OpenHandler {
readonly id = 'preference';
@inject(PreferencesContribution)
protected readonly preferencesContribution: PreferencesContribution;
canHandle(uri: URI): number {
return uri.scheme === this.id ? 500 : -1;
}
async open(uri: URI): Promise<boolean> {
const preferencesWidget = await this.preferencesContribution.openView();
const selector = `li[data-pref-id="${uri.path.toString()}"]:not([data-node-id^="commonly-used@"])`;
const element = document.querySelector(selector);
if (element instanceof HTMLElement) {
if (element.classList.contains('hidden')) {
// We clear the search term as we have clicked on a hidden preference
await preferencesWidget.setSearchTerm('');
await animationFrame();
}
element.scrollIntoView({
block: 'center'
});
element.focus();
return true;
}
return false;
}
}

View File

@@ -0,0 +1,282 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { CancellationError, Listener, ListenerList, MaybePromise, MessageService, nls } from '@theia/core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
import { PreferenceScope } from '@theia/core/lib/common/preferences/preference-scope';
import URI from '@theia/core/lib/common/uri';
import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
import { Mutex, MutexInterface } from 'async-mutex';
import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
import { MonacoJSONCEditor } from './monaco-jsonc-editor';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { IReference } from '@theia/monaco-editor-core/esm/vs/base/common/lifecycle';
@injectable()
/**
* Represents a batch of interactions with an underlying resource.
*/
export abstract class Transaction<Arguments extends unknown[], Result = unknown, Status = unknown> {
protected _open = true;
/**
* Whether the transaction is still accepting new interactions.
* Enqueueing an action when the Transaction is no longer open will throw an error.
*/
get open(): boolean {
return this._open;
}
protected _result = new Deferred<Result | false>();
/**
* The status of the transaction when complete.
*/
get result(): Promise<Result | false> {
return this._result.promise;
}
/**
* The transaction will self-dispose when the queue is empty, once at least one action has been processed.
*/
protected readonly queue = new Mutex(new CancellationError());
protected readonly onWillConcludeListeners = new ListenerList<(Status | false), Promise<void>>();
/**
* An event fired when the transaction is wrapping up.
* Consumers can call `waitUntil` on the event to delay the resolution of the `result` Promise.
*/
onWillConclude = this.onWillConcludeListeners.registration;
protected status = new Deferred<Status>();
/**
* Whether any actions have been added to the transaction.
* The Transaction will not self-dispose until at least one action has been performed.
*/
protected inUse = false;
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
const release = await this.queue.acquire();
try {
const status = await this.setUp();
this.status.resolve(status);
} catch {
this.dispose();
} finally {
release();
}
}
async waitFor(delay?: Promise<unknown>, disposeIfRejected?: boolean): Promise<void> {
try {
await this.queue.runExclusive(() => delay);
} catch {
if (disposeIfRejected) {
this.dispose();
}
}
}
/**
* @returns a promise reflecting the result of performing an action. Typically the promise will not resolve until the whole transaction is complete.
*/
async enqueueAction(...args: Arguments): Promise<Result | false> {
if (this._open) {
let release: MutexInterface.Releaser | undefined;
try {
release = await this.queue.acquire();
if (!this.inUse) {
this.inUse = true;
this.disposeWhenDone();
}
return this.act(...args);
} catch (e) {
if (e instanceof CancellationError) {
throw e;
}
return false;
} finally {
release?.();
}
} else {
throw new Error('Transaction used after disposal.');
}
}
protected disposeWhenDone(): void {
// Due to properties of the micro task system, it's possible for something to have been enqueued between
// the resolution of the waitForUnlock() promise and the the time this code runs, so we have to check.
this.queue.waitForUnlock().then(() => {
if (!this.queue.isLocked()) {
this.dispose();
} else {
this.disposeWhenDone();
}
});
}
protected async conclude(): Promise<void> {
if (this._open) {
try {
this._open = false;
this.queue.cancel();
const result = await this.tearDown();
const status: Status | boolean = (this.status.state === 'unresolved' || this.status.state === 'rejected') ? false : await this.status.promise;
await Listener.awaitAll(status, this.onWillConcludeListeners);
this._result.resolve(result);
} catch {
this._result.resolve(false);
}
}
}
dispose(): void {
this.conclude();
}
/**
* Runs any code necessary to initialize the batch of interactions. No interaction will be run until the setup is complete.
*
* @returns a representation of the success of setup specific to a given transaction implementation.
*/
protected abstract setUp(): MaybePromise<Status>;
/**
* Performs a single interaction
*
* @returns the result of that interaction, specific to a given transaction type.
*/
protected abstract act(...args: Arguments): MaybePromise<Result>;
/**
* Runs any code necessary to complete a transaction and release any resources it holds.
*
* @returns implementation-specific information about the success of the transaction. Will be used as the final status of the transaction.
*/
protected abstract tearDown(): MaybePromise<Result>;
}
export interface PreferenceContext {
getConfigUri(): URI;
getScope(): PreferenceScope;
}
export const PreferenceContext = Symbol('PreferenceContext');
export const PreferenceTransactionPreludeProvider = Symbol('PreferenceTransactionPreludeProvider');
export type PreferenceTransactionPreludeProvider = () => Promise<unknown>;
@injectable()
export class PreferenceTransaction extends Transaction<[string, string[], unknown], boolean, boolean> {
reference: IReference<MonacoEditorModel> | undefined;
@inject(PreferenceContext) protected readonly context: PreferenceContext;
@inject(PreferenceTransactionPreludeProvider) protected readonly prelude?: PreferenceTransactionPreludeProvider;
@inject(MonacoTextModelService) protected readonly textModelService: MonacoTextModelService;
@inject(MonacoJSONCEditor) protected readonly jsoncEditor: MonacoJSONCEditor;
@inject(MessageService) protected readonly messageService: MessageService;
@inject(EditorManager) protected readonly editorManager: EditorManager;
protected override async doInit(): Promise<void> {
this.waitFor(this.prelude?.());
await super.doInit();
}
protected async setUp(): Promise<boolean> {
const reference = await this.textModelService.createModelReference(this.context.getConfigUri()!);
if (this._open) {
this.reference = reference;
} else {
reference.dispose();
return false;
}
if (reference.object.dirty) {
const shouldContinue = await this.handleDirtyEditor();
if (!shouldContinue) {
this.dispose();
return false;
}
}
return true;
}
/**
* @returns whether the setting operation in progress, and any others started in the meantime, should continue.
*/
protected async handleDirtyEditor(): Promise<boolean> {
const saveAndRetry = nls.localizeByDefault('Save and Retry');
const open = nls.localizeByDefault('Open File');
const msg = await this.messageService.error(
// eslint-disable-next-line @theia/localization-check
nls.localizeByDefault('Unable to write into {0} settings because the file has unsaved changes. Please save the {0} settings file first and then try again.',
nls.localizeByDefault(PreferenceScope[this.context.getScope()].toLocaleLowerCase())
),
saveAndRetry, open);
if (this.reference?.object) {
if (msg === open) {
this.editorManager.open(new URI(this.reference.object.uri));
} else if (msg === saveAndRetry) {
await this.reference.object.save();
return true;
}
}
return false;
}
protected async act(key: string, path: string[], value: unknown): Promise<boolean> {
const model = this.reference?.object;
try {
if (model) {
await this.jsoncEditor.setValue(model, path, value);
return this.result;
}
return false;
} catch (e) {
const message = `Failed to update the value of '${key}' in '${this.context.getConfigUri()}'.`;
this.messageService.error(`${message} Please check if it is corrupted.`);
console.error(`${message}`, e);
return false;
}
}
protected async tearDown(): Promise<boolean> {
try {
const model = this.reference?.object;
if (model) {
if (this.status.state === 'resolved' && await this.status.promise) {
await model.save();
return true;
}
}
return false;
} finally {
this.reference?.dispose();
this.reference = undefined;
}
}
}
export interface PreferenceTransactionFactory {
(context: PreferenceContext, waitFor?: Promise<unknown>): PreferenceTransaction;
}
export const PreferenceTransactionFactory = Symbol('PreferenceTransactionFactory');
export const preferenceTransactionFactoryCreator: interfaces.FactoryCreator<PreferenceTransaction> = ({ container }) =>
(context: PreferenceContext, waitFor?: Promise<unknown>) => {
const child = container.createChild();
child.bind(PreferenceContext).toConstantValue(context);
child.bind(PreferenceTransactionPreludeProvider).toConstantValue(() => waitFor);
return child.get(PreferenceTransaction);
};

View File

@@ -0,0 +1,258 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import {
TreeModelImpl,
TreeWidget,
CompositeTreeNode,
TopDownTreeIterator,
TreeNode,
NodeProps,
ExpandableTreeNode,
SelectableTreeNode,
} from '@theia/core/lib/browser';
import { Emitter, PreferenceDataProperty, PreferenceSchemaService, PreferenceService } from '@theia/core';
import { PreferencesSearchbarWidget } from './views/preference-searchbar-widget';
import { PreferenceTreeGenerator } 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/lib/common';
import { COMMONLY_USED_SECTION_PREFIX } from './util/preference-layout';
export interface PreferenceTreeNodeProps extends NodeProps {
visibleChildren: number;
isExpansible?: boolean;
}
export interface PreferenceTreeNodeRow extends Readonly<TreeWidget.NodeRow>, PreferenceTreeNodeProps {
node: Preference.TreeNode;
}
export enum PreferenceFilterChangeSource {
Schema,
Search,
Scope,
}
export interface PreferenceFilterChangeEvent {
source: PreferenceFilterChangeSource
}
@injectable()
export class PreferenceTreeModel extends TreeModelImpl {
@inject(PreferenceSchemaService) protected readonly schemaProvider: PreferenceSchemaService;
@inject(PreferencesSearchbarWidget) protected readonly filterInput: PreferencesSearchbarWidget;
@inject(PreferenceTreeGenerator) protected readonly treeGenerator: PreferenceTreeGenerator;
@inject(PreferencesScopeTabBar) protected readonly scopeTracker: PreferencesScopeTabBar;
@inject(PreferenceService) protected readonly preferenceService: PreferenceService;
protected readonly onTreeFilterChangedEmitter = new Emitter<PreferenceFilterChangeEvent>();
readonly onFilterChanged = this.onTreeFilterChangedEmitter.event;
protected lastSearchedFuzzy: string = '';
protected lastSearchedLiteral: string = '';
protected lastSearchedTags: string[] = [];
protected _currentScope: number = Number(Preference.DEFAULT_SCOPE.scope);
protected _isFiltered: boolean = false;
protected _currentRows: Map<string, PreferenceTreeNodeRow> = new Map();
protected _totalVisibleLeaves = 0;
get currentRows(): Readonly<Map<string, PreferenceTreeNodeRow>> {
return this._currentRows;
}
get totalVisibleLeaves(): number {
return this._totalVisibleLeaves;
}
get isFiltered(): boolean {
return this._isFiltered;
}
get propertyList(): ReadonlyMap<string, PreferenceDataProperty> {
return this.schemaProvider.getSchemaProperties();
}
get currentScope(): Preference.SelectedScopeDetails {
return this.scopeTracker.currentScope;
}
get onSchemaChanged(): Event<CompositeTreeNode> {
return this.treeGenerator.onSchemaChanged;
}
@postConstruct()
protected override init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
super.init();
this.toDispose.pushAll([
this.treeGenerator.onSchemaChanged(newTree => this.handleNewSchema(newTree)),
this.scopeTracker.onScopeChanged(scopeDetails => {
this._currentScope = scopeDetails.scope;
this.updateFilteredRows(PreferenceFilterChangeSource.Scope);
}),
this.filterInput.onFilterChanged(newSearchTerm => {
this.lastSearchedTags = Array.from(newSearchTerm.matchAll(/@tag:([^\s]+)/g)).map(match => match[0].slice(5));
const newSearchTermWithoutTags = newSearchTerm.replace(/@tag:[^\s]+/g, '');
this.lastSearchedLiteral = newSearchTermWithoutTags;
this.lastSearchedFuzzy = newSearchTermWithoutTags.replace(/\s/g, '');
this._isFiltered = newSearchTerm.length > 2;
if (this.isFiltered) {
this.expandAll();
} else if (CompositeTreeNode.is(this.root)) {
this.collapseAll(this.root);
}
this.updateFilteredRows(PreferenceFilterChangeSource.Search);
}),
this.onFilterChanged(() => {
this.filterInput.updateResultsCount(this._totalVisibleLeaves);
}),
this.onTreeFilterChangedEmitter,
]);
await this.preferenceService.ready;
this.handleNewSchema(this.treeGenerator.root);
}
private handleNewSchema(newRoot: CompositeTreeNode): void {
this.root = newRoot;
if (this.isFiltered) {
this.expandAll();
}
this.updateFilteredRows(PreferenceFilterChangeSource.Schema);
}
protected updateRows(): void {
const root = this.root;
this._currentRows = new Map();
if (root) {
this._totalVisibleLeaves = 0;
let index = 0;
for (const node of new TopDownTreeIterator(root, {
pruneCollapsed: false,
pruneSiblings: true
})) {
if (TreeNode.isVisible(node) && Preference.TreeNode.is(node)) {
const { id } = Preference.TreeNode.getGroupAndIdFromNodeId(node.id);
if (CompositeTreeNode.is(node) || this.passesCurrentFilters(node, id)) {
this.updateVisibleChildren(node);
this._currentRows.set(node.id, {
index: index++,
node,
depth: node.depth,
visibleChildren: 0,
});
}
}
}
}
}
protected updateFilteredRows(source: PreferenceFilterChangeSource): void {
this.updateRows();
this.onTreeFilterChangedEmitter.fire({ source });
}
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;
}
if (!this.lastSearchedTags.every(tag => node.preference.data.tags?.includes(tag))) {
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 override isVisibleSelectableNode(node: TreeNode): node is SelectableTreeNode {
return CompositeTreeNode.is(node) && !!this._currentRows.get(node.id)?.visibleChildren;
}
protected updateVisibleChildren(node: TreeNode): void {
if (!CompositeTreeNode.is(node)) {
this._totalVisibleLeaves++;
let nextParent = node.parent?.id && this._currentRows.get(node.parent?.id);
while (nextParent && nextParent.node !== this.root) {
if (nextParent) {
nextParent.visibleChildren += 1;
}
nextParent = nextParent.node.parent?.id && this._currentRows.get(nextParent.node.parent?.id);
if (nextParent) {
nextParent.isExpansible = true;
}
}
}
}
collapseAllExcept(openNode: TreeNode | undefined): void {
const openNodes: TreeNode[] = [];
while (ExpandableTreeNode.is(openNode)) {
openNodes.push(openNode);
this.expandNode(openNode);
openNode = openNode.parent;
}
if (CompositeTreeNode.is(this.root)) {
this.root.children.forEach(child => {
if (!openNodes.includes(child) && 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);
}
});
}
}
getNodeFromPreferenceId(id: string): Preference.TreeNode | undefined {
const node = this.getNode(this.treeGenerator.getNodeId(id));
return node && Preference.TreeNode.is(node) ? node : undefined;
}
/**
* @returns true if selection changed, false otherwise
*/
selectIfNotSelected(node: SelectableTreeNode): boolean {
const currentlySelected = this.selectedNodes[0];
if (!node.selected || node !== currentlySelected) {
node.selected = true;
this.selectNode(node);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,256 @@
// *****************************************************************************
// Copyright (C) 2018 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, optional } from '@theia/core/shared/inversify';
import { MenuModelRegistry, CommandRegistry, nls, PreferenceScope, PreferenceService } from '@theia/core';
import {
CommonMenus,
AbstractViewContribution,
CommonCommands,
KeybindingRegistry,
Widget,
QuickInputService,
QuickPickItem,
isFirefox,
} from '@theia/core/lib/browser';
import { isOSX } from '@theia/core/lib/common/os';
import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
import URI from '@theia/core/lib/common/uri';
import { PreferencesWidget } from './views/preference-widget';
import { Preference, PreferencesCommands, PreferenceMenus } from './util/preference-types';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { FileStat } from '@theia/filesystem/lib/common/files';
@injectable()
export class PreferencesContribution extends AbstractViewContribution<PreferencesWidget> {
@inject(FileService) protected readonly fileService: FileService;
@inject(EditorManager) protected readonly editorManager: EditorManager;
@inject(PreferenceService) protected readonly preferenceService: PreferenceService;
@inject(ClipboardService) protected readonly clipboardService: ClipboardService;
@inject(PreferencesWidget) protected readonly scopeTracker: PreferencesWidget;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(QuickInputService) @optional() protected readonly quickInputService: QuickInputService;
constructor() {
super({
widgetId: PreferencesWidget.ID,
widgetName: PreferencesWidget.LABEL,
defaultWidgetOptions: {
area: 'main',
},
});
}
override registerCommands(commands: CommandRegistry): void {
commands.registerCommand(CommonCommands.OPEN_PREFERENCES, {
execute: async (query?: string) => {
const widget = await this.openView({ activate: true });
if (typeof query === 'string') {
widget.setSearchTerm(query);
}
},
});
commands.registerCommand(PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR, {
isEnabled: () => true,
isVisible: w => this.withWidget(w, () => true),
execute: (preferenceId: string) => {
this.openPreferencesJSON(preferenceId);
}
});
commands.registerCommand(PreferencesCommands.COPY_JSON_NAME, {
isEnabled: Preference.EditorCommandArgs.is,
isVisible: Preference.EditorCommandArgs.is,
execute: ({ id, value }: Preference.EditorCommandArgs) => {
this.clipboardService.writeText(id);
}
});
commands.registerCommand(PreferencesCommands.COPY_JSON_VALUE, {
isEnabled: Preference.EditorCommandArgs.is,
isVisible: Preference.EditorCommandArgs.is,
execute: ({ id, value }: { id: string, value: string; }) => {
const jsonString = `"${id}": ${JSON.stringify(value)}`;
this.clipboardService.writeText(jsonString);
}
});
commands.registerCommand(PreferencesCommands.RESET_PREFERENCE, {
isEnabled: Preference.EditorCommandArgs.is,
isVisible: Preference.EditorCommandArgs.is,
execute: ({ id }: Preference.EditorCommandArgs) => {
this.preferenceService.set(id, undefined, Number(this.scopeTracker.currentScope.scope), this.scopeTracker.currentScope.uri);
}
});
commands.registerCommand(PreferencesCommands.OPEN_USER_PREFERENCES, {
execute: async () => {
const widget = await this.openView({ activate: true });
widget.setScope(PreferenceScope.User);
}
});
commands.registerCommand(PreferencesCommands.OPEN_WORKSPACE_PREFERENCES, {
isEnabled: () => !!this.workspaceService.workspace,
isVisible: () => !!this.workspaceService.workspace,
execute: async () => {
const widget = await this.openView({ activate: true });
widget.setScope(PreferenceScope.Workspace);
}
});
commands.registerCommand(PreferencesCommands.OPEN_FOLDER_PREFERENCES, {
isEnabled: () => !!this.workspaceService.isMultiRootWorkspaceOpened && this.workspaceService.tryGetRoots().length > 0,
isVisible: () => !!this.workspaceService.isMultiRootWorkspaceOpened && this.workspaceService.tryGetRoots().length > 0,
execute: () => this.openFolderPreferences(root => {
this.openView({ activate: true });
this.scopeTracker.setScope(root.resource);
})
});
commands.registerCommand(PreferencesCommands.OPEN_USER_PREFERENCES_JSON, {
execute: async () => this.openJson(PreferenceScope.User)
});
commands.registerCommand(PreferencesCommands.OPEN_WORKSPACE_PREFERENCES_JSON, {
isEnabled: () => !!this.workspaceService.workspace,
isVisible: () => !!this.workspaceService.workspace,
execute: async () => this.openJson(PreferenceScope.Workspace)
});
commands.registerCommand(PreferencesCommands.OPEN_FOLDER_PREFERENCES_JSON, {
isEnabled: () => !!this.workspaceService.isMultiRootWorkspaceOpened && this.workspaceService.tryGetRoots().length > 0,
isVisible: () => !!this.workspaceService.isMultiRootWorkspaceOpened && this.workspaceService.tryGetRoots().length > 0,
execute: () => this.openFolderPreferences(root => this.openJson(PreferenceScope.Folder, root.resource.toString()))
});
}
override registerMenus(menus: MenuModelRegistry): void {
menus.registerMenuAction(CommonMenus.FILE_SETTINGS_SUBMENU_OPEN, {
commandId: CommonCommands.OPEN_PREFERENCES.id,
label: nls.localizeByDefault('Settings'),
order: 'a10',
});
menus.registerMenuAction(CommonMenus.MANAGE_SETTINGS, {
commandId: CommonCommands.OPEN_PREFERENCES.id,
label: nls.localizeByDefault('Settings'),
order: 'a10',
});
menus.registerMenuAction(PreferenceMenus.PREFERENCE_EDITOR_CONTEXT_MENU, {
commandId: PreferencesCommands.RESET_PREFERENCE.id,
label: PreferencesCommands.RESET_PREFERENCE.label,
order: 'a'
});
menus.registerMenuAction(PreferenceMenus.PREFERENCE_EDITOR_COPY_ACTIONS, {
commandId: PreferencesCommands.COPY_JSON_VALUE.id,
label: PreferencesCommands.COPY_JSON_VALUE.label,
order: 'b'
});
menus.registerMenuAction(PreferenceMenus.PREFERENCE_EDITOR_COPY_ACTIONS, {
commandId: PreferencesCommands.COPY_JSON_NAME.id,
label: PreferencesCommands.COPY_JSON_NAME.label,
order: 'c'
});
}
override registerKeybindings(keybindings: KeybindingRegistry): void {
keybindings.registerKeybinding({
command: CommonCommands.OPEN_PREFERENCES.id,
keybinding: (isOSX && !isFirefox) ? 'cmd+,' : 'ctrl+,'
});
}
registerToolbarItems(toolbar: TabBarToolbarRegistry): void {
toolbar.registerItem({
id: PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR.id,
command: PreferencesCommands.OPEN_PREFERENCES_JSON_TOOLBAR.id,
tooltip: PreferencesCommands.OPEN_USER_PREFERENCES_JSON.label,
priority: 0,
});
}
protected async openPreferencesJSON(opener: string | PreferencesWidget): Promise<void> {
const { scope, activeScopeIsFolder, uri } = this.scopeTracker.currentScope;
const scopeID = Number(scope);
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);
}
let jsonEditorWidget: EditorWidget;
const jsonUriToOpen = await this.obtainConfigUri(scopeID, activeScopeIsFolder, uri);
if (jsonUriToOpen) {
jsonEditorWidget = await this.editorManager.open(jsonUriToOpen);
if (preferenceId) {
const text = jsonEditorWidget.editor.document.getText();
if (preferenceId) {
const { index } = text.match(preferenceId)!;
const numReturns = text.slice(0, index).match(new RegExp('\n', 'g'))!.length;
jsonEditorWidget.editor.cursor = { line: numReturns, character: 4 + preferenceId.length + 4 };
}
}
}
}
protected async openJson(scope: PreferenceScope, resource?: string): Promise<void> {
const jsonUriToOpen = await this.obtainConfigUri(scope, false, resource);
if (jsonUriToOpen) {
await this.editorManager.open(jsonUriToOpen);
}
}
/**
* Prompts which workspace root folder to open the JSON settings.
*/
protected async openFolderPreferences(callback: (root: FileStat) => unknown): Promise<void> {
const roots = this.workspaceService.tryGetRoots();
if (roots.length === 1) {
callback(roots[0]);
} else {
const items: QuickPickItem[] = roots.map(root => ({
label: root.name,
description: root.resource.path.fsPath(),
execute: () => callback(root)
}));
this.quickInputService?.showQuickPick(items, { placeholder: 'Select workspace folder' });
}
}
private async obtainConfigUri(serializedScope: number, activeScopeIsFolder: boolean, resource?: string): Promise<URI | undefined> {
let scope: PreferenceScope = serializedScope;
if (activeScopeIsFolder) {
scope = PreferenceScope.Folder;
}
const resourceUri = !!resource ? resource : undefined;
const configUri = this.preferenceService.getConfigUri(scope, resourceUri);
if (!configUri) {
return undefined;
}
if (configUri && !await this.fileService.exists(configUri)) {
await this.fileService.create(configUri);
}
return configUri;
}
/**
* Determine if the current widget is the PreferencesWidget.
*/
protected withWidget<T>(widget: Widget | undefined = this.tryGetWidget(), fn: (widget: PreferencesWidget) => T): T | false {
if (widget instanceof PreferencesWidget && widget.id === PreferencesWidget.ID) {
return fn(widget);
}
return false;
}
}

View File

@@ -0,0 +1,86 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { JsonSchemaRegisterContext, JsonSchemaContribution, JsonSchemaDataStore } from '@theia/core/lib/browser/json-schema-store';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { PreferenceSchemaService, PreferenceConfigurations, PreferenceScope } from '@theia/core';
import { UserStorageUri } from '@theia/userstorage/lib/browser';
import debounce = require('@theia/core/shared/lodash.debounce');
const PREFERENCE_URI_PREFIX = 'vscode://schemas/settings/';
const DEBOUNCED_UPDATE_DELAY = 200;
@injectable()
export class PreferencesJsonSchemaContribution implements JsonSchemaContribution {
@inject(PreferenceSchemaService)
protected readonly schemaProvider: PreferenceSchemaService;
@inject(PreferenceConfigurations)
protected readonly preferenceConfigurations: PreferenceConfigurations;
@inject(JsonSchemaDataStore)
protected readonly jsonSchemaData: JsonSchemaDataStore;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
protected readonly debouncedUpdateInMemoryResources = debounce(() => this.updateInMemoryResources(), DEBOUNCED_UPDATE_DELAY);
registerSchemas(context: JsonSchemaRegisterContext): void {
this.registerSchema(PreferenceScope.Default, context);
this.registerSchema(PreferenceScope.User, context);
this.registerSchema(PreferenceScope.Workspace, context);
this.registerSchema(PreferenceScope.Folder, context);
context.registerSchema({
fileMatch: `file://**/${this.preferenceConfigurations.getConfigName()}.json`,
url: this.getSchemaURIForScope(PreferenceScope.Folder).toString()
});
context.registerSchema({
fileMatch: UserStorageUri.resolve(this.preferenceConfigurations.getConfigName() + '.json').toString(),
url: this.getSchemaURIForScope(PreferenceScope.User).toString()
});
this.workspaceService.updateSchema('settings', { $ref: this.getSchemaURIForScope(PreferenceScope.Workspace).toString() });
this.schemaProvider.onDidChangeSchema(() => this.debouncedUpdateInMemoryResources());
}
protected registerSchema(scope: PreferenceScope, context: JsonSchemaRegisterContext): void {
const scopeStr = PreferenceScope[scope].toLowerCase();
const uri = new URI(PREFERENCE_URI_PREFIX + scopeStr);
this.jsonSchemaData.setSchema(uri, (this.schemaProvider.getJSONSchema(scope)));
}
protected updateInMemoryResources(): void {
this.jsonSchemaData.setSchema(this.getSchemaURIForScope(PreferenceScope.Default),
(this.schemaProvider.getJSONSchema(PreferenceScope.Default)));
this.jsonSchemaData.setSchema(this.getSchemaURIForScope(PreferenceScope.User),
this.schemaProvider.getJSONSchema(PreferenceScope.User));
this.jsonSchemaData.setSchema(this.getSchemaURIForScope(PreferenceScope.Workspace),
this.schemaProvider.getJSONSchema(PreferenceScope.Workspace));
this.jsonSchemaData.setSchema(this.getSchemaURIForScope(PreferenceScope.Folder),
this.schemaProvider.getJSONSchema(PreferenceScope.Folder));
}
protected getSchemaURIForScope(scope: PreferenceScope): URI {
return new URI(PREFERENCE_URI_PREFIX + PreferenceScope[scope].toLowerCase());
}
}

View File

@@ -0,0 +1,27 @@
// *****************************************************************************
// Copyright (C) 2018 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import * as monaco from '@theia/monaco-editor-core';
monaco.languages.register({
id: 'jsonc',
'aliases': [
'JSON with Comments'
],
'filenames': [
'settings.json'
]
});

View File

@@ -0,0 +1,479 @@
/********************************************************************************
* Copyright (C) 2019 TypeFox 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-only WITH Classpath-exception-2.0
********************************************************************************/
#preferences_container_widget .lm-SplitPanel-handle {
border-right: var(--theia-border-width) solid var(--theia-editorGroup-border);
}
#preferences_container_widget .lm-TabBar-tabIcon {
align-items: center;
display: flex;
line-height: var(--theia-content-line-height) !important;
}
/* UI View */
@import url("./preference-context-menu.css");
@import url("./preference-array.css");
@import url("./preference-file.css");
@import url("./preference-object.css");
@import url("./search-input.css");
.theia-settings-container {
max-width: 1000px;
padding-top: 11px;
display: grid;
grid-template-areas:
"header header"
"tabbar tabbar"
"navbar editor";
grid-template-columns: minmax(150px, 280px) 1fr;
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);
padding-left: var(--theia-ui-padding);
margin: calc(2 * var(--theia-ui-padding)) 0px;
}
.theia-settings-container .preferences-searchbar-widget {
grid-area: header;
margin: 3px 24px 0px 24px;
}
.theia-settings-container .preferences-tabbar-widget {
grid-area: tabbar;
margin: 3px 24px 0px 24px;
}
.theia-settings-container .preferences-tabbar-widget.with-shadow {
box-shadow: 0px 6px 5px -5px var(--theia-widget-shadow);
}
.theia-settings-container .preferences-tabbar-widget .preferences-scope-tab .lm-TabBar-tabIcon:not(.preferences-folder-dropdown-icon) {
display: none;
}
#theia-main-content-panel .theia-settings-container #preferences-scope-tab-bar .preferences-scope-tab {
background: var(--theia-editor-background);
border-right: unset;
border-bottom: var(--theia-border-width) solid var(--theia-tab-unfocusedInactiveForeground);
border-top: none;
}
#theia-main-content-panel .theia-settings-container .tabbar-underline {
width: 100%;
position: absolute;
border-top: 1px solid var(--theia-tab-unfocusedInactiveForeground);
z-index: -1;
}
#theia-main-content-panel .theia-settings-container #preferences-scope-tab-bar .preferences-scope-tab {
color: var(--theia-panelTitle-inactiveForeground);
}
#theia-main-content-panel .theia-settings-container #preferences-scope-tab-bar .preferences-scope-tab:hover {
color: var(--theia-panelTitle-activeForeground);
}
#theia-main-content-panel .theia-settings-container #preferences-scope-tab-bar .preferences-scope-tab.lm-mod-current {
color: var(--theia-panelTitle-activeForeground);
border-bottom: var(--theia-border-width) solid var(--theia-panelTitle-activeBorder);
}
#theia-main-content-panel .theia-settings-container #preferences-scope-tab-bar .preferences-scope-tab.lm-mod-current:not(.theia-mod-active) {
border-top: unset;
}
#theia-main-content-panel .theia-settings-container #preferences-scope-tab-bar .preferences-scope-tab.preferences-folder-tab .lm-TabBar-tabLabel::after {
content: "Folder";
padding-left: 4px;
font-size: 0.8em;
color: var(--theia-tab-inactiveForeground);
}
#theia-main-content-panel .theia-settings-container #preferences-scope-tab-bar .preferences-scope-tab.preferences-folder-dropdown {
position: relative;
padding-right: 23px;
}
.preferences-folder-dropdown-icon {
width: 15px;
height: 15px;
position: absolute;
right: var(--theia-ui-padding);
}
.theia-settings-container .preferences-editor-widget {
grid-area: editor;
overflow: hidden;
}
.theia-settings-container .preferences-editor-widget.full-pane {
grid-column-start: 1;
grid-column-end: 3;
}
.theia-settings-container .preferences-tree-widget {
grid-area: navbar;
}
.theia-settings-container .preferences-tree-widget .theia-mod-selected {
font-weight: bold;
}
.theia-settings-container .preferences-tree-widget .theia-TreeNodeSegment {
text-overflow: ellipsis;
overflow: hidden;
max-width: 90%;
}
.theia-settings-container .settings-main {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
.theia-settings-container .settings-main-scroll-container {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 0 24px;
flex: 1 1 auto;
}
.theia-settings-container .settings-main-sticky-misc {
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 1 50px;
}
.theia-settings-container .settings-main-sticky-misc .json-button>i {
display: inline-block;
background: var(--theia-icon-open-json) no-repeat;
background-position-y: 1px;
-webkit-filter: invert(1);
filter: invert(1);
height: var(--theia-icon-size);
width: var(--theia-icon-size);
}
.theia-settings-container .settings-scope>label {
margin-right: 12px;
}
.theia-settings-container .settings-section {
padding-left: 0;
padding-top: var(--theia-ui-padding);
margin-top: calc(var(--theia-ui-padding) * -1);
}
.theia-settings-container .settings-section a {
border: none;
color: var(--theia-foreground);
font-weight: 500;
outline: 0;
text-decoration: none;
}
.theia-settings-container .command-link {
color: var(--theia-textLink-foreground);
}
.theia-settings-container .settings-section a:hover {
text-decoration: underline;
}
.theia-settings-container .settings-section-category-title {
font-weight: bold;
font-size: var(--theia-ui-font-size3);
}
.theia-settings-container .settings-section-subcategory-title {
font-weight: bold;
font-size: var(--theia-ui-font-size2);
}
.theia-settings-container .settings-section>li {
list-style-type: none;
margin: var(--theia-ui-padding) 0px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-box-align: start;
-ms-flex-align: start;
align-items: start;
}
.theia-settings-container li.single-pref {
list-style-type: none;
padding: 12px 14px 18px;
width: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
position: relative;
}
.theia-settings-container li.single-pref p {
margin: 0;
}
.theia-settings-container li.single-pref:hover:not(:focus) {
background-color: var(--theia-settings-rowHoverBackground);
}
.theia-settings-container li.single-pref:focus {
background-color: var(--theia-settings-focusedRowBackground);
outline: 1px solid var(--theia-settings-focusedRowBorder);
}
.theia-settings-container li.single-pref .pref-context-gutter {
position: absolute;
height: calc(100% - 36px);
left: -22px;
padding-right: 8px;
border-right: 2px hidden;
}
.theia-settings-container .settings-context-menu-btn {
opacity: 0;
transition: opacity 0.5s;
}
.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;
}
.theia-settings-container li.single-pref .pref-context-gutter.theia-mod-item-modified {
border-right: 2px solid var(--theia-settings-modifiedItemIndicator);
}
.theia-settings-container li.single-pref input[type="text"] {
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.theia-settings-container .settings-main {
margin: 0;
}
.theia-settings-container .settings-main-sticky {
top: 0;
padding-top: calc(var(--theia-ui-padding));
margin-top: calc(var(--theia-ui-padding) * -1);
background-color: var(--theia-editor-background);
-webkit-box-sizing: border-box;
box-sizing: border-box;
z-index: 1000;
display: flex;
flex-direction: column;
justify-content: space-around;
}
.theia-settings-container .pref-name {
padding: 0;
font-weight: bold;
display: flex;
align-items: center;
gap: var(--theia-ui-padding);
}
.theia-settings-container .preference-leaf-headline-prefix {
color: var(--theia-descriptionForeground);
}
.theia-settings-container .preference-leaf-headline-tags {
display: flex;
align-items: center;
gap: calc(var(--theia-ui-padding) / 2);
}
.theia-settings-container .preference-tag {
display: inline-block;
padding: calc(var(--theia-ui-padding) / 8) calc(var(--theia-ui-padding) / 2);
font-size: 0.85em;
font-weight: normal;
font-style: italic;
color: var(--theia-descriptionForeground);
background-color: var(--theia-badge-background);
border-radius: calc(var(--theia-ui-padding) / 4);
border: 1px solid var(--theia-contrastBorder, transparent);
}
.preferences-tree-spacer {
padding-left: calc(var(--theia-ui-padding) / 2);
padding-right: calc(var(--theia-ui-padding) / 2);
min-width: var(--theia-icon-size);
min-height: var(--theia-icon-size);
}
.theia-settings-container .pref-description {
padding: var(--theia-ui-padding) 0;
color: var(--theia-descriptionForeground);
line-height: 18px;
}
.theia-settings-container .pref-description a {
text-decoration-line: none;
cursor: pointer;
}
.theia-settings-container .theia-select:focus {
outline-width: 1px;
outline-style: solid;
outline-offset: -1px;
opacity: 1 !important;
outline-color: var(--theia-focusBorder);
}
.theia-settings-container .theia-input[type="text"] {
border: 1px solid var(--theia-dropdown-border);
}
.theia-settings-container .theia-input[type="checkbox"]:focus,
.theia-settings-container .theia-input[type="number"]:focus {
outline-width: 1px;
}
.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;
border: 1px solid var(--theia-dropdown-border);
}
/* 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;
}
.dialogContent .error:not(:empty),
.theia-settings-container .pref-content-container .pref-input .pref-input-container .pref-error-notification {
border-style: solid;
border-width: 1px;
border-color: var(--theia-inputValidation-errorBorder);
background-color: var(--theia-inputValidation-errorBackground);
width: 100%;
box-sizing: border-box;
padding: var(--theia-ui-padding);
}
.theia-settings-container .pref-content-container .pref-input .pref-input-container {
display: flex;
flex-direction: column;
}
.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 {
text-decoration: none;
cursor: pointer;
}
.theia-settings-container .pref-content-container {
width: 100%;
}
.theia-settings-container .pref-content-container .pref-input {
padding: var(--theia-ui-padding) 0;
width: 100%;
max-width: 320px;
}
.theia-settings-container .pref-content-container .pref-input>select,
.theia-settings-container .pref-content-container .pref-input>input:not([type="checkbox"]) {
width: 100%;
}
/* These specifications for the boolean class ensure that the
checkbox is rendered to the left of the description.
*/
.theia-settings-container .pref-content-container.boolean {
display: grid;
grid-template-columns: 20px 1fr;
}
.theia-settings-container .pref-content-container.boolean .pref-description {
grid-column-start: 2;
grid-row-start: 1;
}
.theia-settings-container .pref-content-container.boolean .pref-input {
grid-column-start: 1;
grid-row-start: 1;
margin: 0;
}
.theia-settings-container .settings-section>li:last-child {
margin-bottom: 20px;
}
.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: ", ";
}
/** Select component */
.theia-settings-container .theia-select-component {
height: 28px;
width: 100%;
max-width: 320px;
}

View File

@@ -0,0 +1,94 @@
/********************************************************************************
* 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-only WITH Classpath-exception-2.0
********************************************************************************/
.theia-settings-container .preference-array {
list-style: none;
padding: 0;
}
.theia-settings-container .preference-array-element {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: calc(var(--theia-ui-padding) / 2) var(--theia-ui-padding);
border-bottom: var(--theia-panel-border) 2px solid;
}
.theia-settings-container .pref-input li:nth-last-child(2) {
border-bottom: none;
}
.theia-settings-container .pref-input li:last-child {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.theia-settings-container .preference-array-element:hover {
background-color: rgba(50%, 50%, 50%, 0.1);
}
.theia-settings-container .preference-array-element-btn {
width: 1.5em;
height: 1.5em;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.theia-settings-container
.preference-array-element
.preference-array-element-btn {
opacity: 0;
}
.theia-settings-container
.preference-array-element:hover
.preference-array-element-btn {
opacity: 1;
}
.theia-settings-container .preference-array-element-btn:hover {
background-color: rgba(50%, 50%, 50%, 0.1);
cursor: pointer;
}
.theia-settings-container .preference-array .codicon.codicon-add {
margin-left: calc((var(--theia-icon-size) + 4px) * -1);
margin-right: 4px;
width: var(--theia-icon-size);
height: var(--theia-icon-size);
}
.theia-settings-container .preference-array-input {
padding-right: calc(var(--theia-icon-size) + var(--theia-ui-padding));
width: 100%;
}

View File

@@ -0,0 +1,74 @@
/********************************************************************************
* 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-only WITH Classpath-exception-2.0
********************************************************************************/
.theia-settings-container .settings-context-menu-container {
position: relative;
padding-left: var(--theia-ui-padding);
}
.theia-settings-container .settings-context-menu-btn {
cursor: pointer;
}
.theia-settings-container .settings-context-menu {
position: absolute;
width: var(--theia-settingsSidebar-width);
list-style: none;
padding: var(--theia-ui-padding);
bottom: calc(100% + 10px);
left: -10px;
z-index: 9999;
background-color: var(--theia-menu-background);
}
.theia-settings-container .settings-context-menu:before {
content: "";
position: absolute;
left: 10px;
bottom: -10px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid var(--theia-menu-background);
}
.theia-settings-container .settings-context-menu li {
padding: var(--theia-ui-padding);
}
.theia-settings-container .settings-context-menu li:hover {
background-color: var(--theia-menu-selectionBackground);
}
.theia-settings-container .settings-context-menu i {
padding-right: var(--theia-ui-padding);
width: var(--theia-icon-size);
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.theia-settings-container .pref-context-menu-btn {
margin-left: 5px;
}
.theia-settings-container .pref-context-menu-btn:hover {
background-color: rgba(50%, 50%, 50%, 0.1);
}

View File

@@ -0,0 +1,30 @@
/********************************************************************************
* Copyright (C) 2022 EclipseSource 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-only WITH Classpath-exception-2.0
********************************************************************************/
.theia-settings-container .preference-file-container {
position: relative;
display: flex;
align-items: center;
}
.theia-settings-container .preference-file-input {
flex: 1;
padding-right: 4px;
}
.theia-settings-container .preference-file-button {
margin-left: var(--theia-ui-padding);
}

View File

@@ -0,0 +1,49 @@
/********************************************************************************
* 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-only WITH Classpath-exception-2.0
********************************************************************************/
.theia-settings-container .object-preference-input-container {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.theia-settings-container .object-preference-input {
width: 100%;
max-height: 250px;
resize: none;
color: var(--theia-settings-textInputForeground);
background-color: var(--theia-settings-textInputBackground);
border-color: var(--theia-panel-border);
font-size: var(--theia-code-font-size);
margin-bottom: 10px;
}
.theia-settings-container .object-preference-input-btn-toggle {
padding: 0 calc(var(--theia-ui-padding) / 2);
}
.theia-settings-container .object-preference-input-btn-toggle-icon {
display: inline-block;
background: var(--theia-icon-open-json) no-repeat;
background-position-y: 1px;
height: var(--theia-icon-size);
width: var(--theia-icon-size);
}

View File

@@ -0,0 +1,66 @@
/********************************************************************************
* 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-only WITH Classpath-exception-2.0
********************************************************************************/
.theia-settings-container .settings-search-container {
display: flex;
align-items: center;
}
.theia-settings-container .settings-search-container .settings-search-input {
flex: 1;
text-indent: 8px;
padding: calc(var(--theia-ui-padding) / 2) 0;
box-sizing: border-box;
border: 1px solid var(--theia-dropdown-border);
}
.theia-settings-container .settings-search-container .option-buttons {
height: 23px;
align-items: center;
position: absolute;
z-index: 999;
right: 5px;
display: flex;
}
.theia-settings-container .settings-search-container .clear-all {
background: var(--theia-icon-clear);
}
.theia-settings-container .settings-search-container .results-found {
background-color: var(--theia-badge-background);
border-radius: 2px;
color: var(--theia-badge-foreground);
padding: calc(var(--theia-ui-padding) / 5) calc(var(--theia-ui-padding) / 2);
}
.theia-settings-container .settings-search-container .option {
width: 21px;
height: 21px;
margin: 0 1px;
display: inline-block;
box-sizing: border-box;
user-select: none;
background-repeat: no-repeat;
background-position: center;
border: var(--theia-border-width) solid transparent;
opacity: 0.7;
cursor: pointer;
}
.theia-settings-container .settings-search-container .enabled {
opacity: 1;
}

View File

@@ -0,0 +1,454 @@
// *****************************************************************************
// Copyright (C) 2024 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { nls } from '@theia/core';
import { injectable } from '@theia/core/shared/inversify';
export interface PreferenceLayout {
id: string;
label: string;
children?: PreferenceLayout[];
settings?: string[];
}
export const COMMONLY_USED_SECTION_PREFIX = 'commonly-used';
export const COMMONLY_USED_LAYOUT = {
id: COMMONLY_USED_SECTION_PREFIX,
label: nls.localizeByDefault('Commonly Used'),
settings: [
'files.autoSave',
'editor.fontSize',
'editor.fontFamily',
'editor.tabSize',
'editor.renderWhitespace',
'editor.cursorStyle',
'editor.multiCursorModifier',
'editor.insertSpaces',
'editor.wordWrap',
'files.exclude',
'files.associations'
]
};
export const DEFAULT_LAYOUT: PreferenceLayout[] = [
{
id: 'editor',
label: nls.localizeByDefault('Text Editor'),
settings: ['editor.*'],
children: [
{
id: 'editor.cursor',
label: nls.localizeByDefault('Cursor'),
settings: ['editor.cursor*']
},
{
id: 'editor.find',
label: nls.localizeByDefault('Find'),
settings: ['editor.find.*']
},
{
id: 'editor.font',
label: nls.localizeByDefault('Font'),
settings: ['editor.font*']
},
{
id: 'editor.format',
label: nls.localizeByDefault('Formatting'),
settings: ['editor.format*']
},
{
id: 'editor.diffEditor',
label: nls.localizeByDefault('Diff Editor'),
settings: ['diffEditor.*']
},
{
id: 'editor.multiDiffEditor',
label: nls.localizeByDefault('Multi-File Diff Editor'),
settings: ['multiDiffEditor.*']
},
{
id: 'editor.minimap',
label: nls.localizeByDefault('Minimap'),
settings: ['editor.minimap.*']
},
{
id: 'editor.suggestions',
label: nls.localizeByDefault('Suggestions'),
settings: ['editor.*suggest*']
},
{
id: 'editor.files',
label: nls.localizeByDefault('Files'),
settings: ['files.*']
}
]
},
{
id: 'workbench',
label: nls.localizeByDefault('Workbench'),
settings: ['workbench.*', 'workspace.*'],
children: [
{
id: 'workbench.appearance',
label: nls.localizeByDefault('Appearance'),
settings: [
'workbench.activityBar.*', 'workbench.*color*', 'workbench.fontAliasing', 'workbench.iconTheme', 'workbench.sidebar.location',
'workbench.*.visible', 'workbench.tips.enabled', 'workbench.tree.*', 'workbench.view.*'
]
},
{
id: 'workbench.breadcrumbs',
label: nls.localizeByDefault('Breadcrumbs'),
settings: ['breadcrumbs.*']
},
{
id: 'workbench.editor',
label: nls.localizeByDefault('Editor Management'),
settings: ['workbench.editor.*']
},
{
id: 'workbench.settings',
label: nls.localizeByDefault('Settings Editor'),
settings: ['workbench.settings.*']
},
{
id: 'workbench.zenmode',
label: nls.localizeByDefault('Zen Mode'),
settings: ['zenmode.*']
},
{
id: 'workbench.screencastmode',
label: nls.localizeByDefault('Screencast Mode'),
settings: ['screencastMode.*']
}
]
},
{
id: 'window',
label: nls.localizeByDefault('Window'),
settings: ['window.*'],
children: [
{
id: 'window.newWindow',
label: nls.localizeByDefault('New Window'),
settings: ['window.*newwindow*']
}
]
},
{
id: 'features',
label: nls.localizeByDefault('Features'),
children: [
{
id: 'features.accessibilitySignals',
label: nls.localizeByDefault('Accessibility Signals'),
settings: ['accessibility.signal*']
},
{
id: 'features.accessibility',
label: nls.localizeByDefault('Accessibility'),
settings: ['accessibility.*']
},
{
id: 'features.explorer',
label: nls.localizeByDefault('Explorer'),
settings: ['explorer.*', 'outline.*']
},
{
id: 'features.search',
label: nls.localizeByDefault('Search'),
settings: ['search.*']
},
{
id: 'features.debug',
label: nls.localizeByDefault('Debug'),
settings: ['debug.*', 'launch']
},
{
id: 'features.testing',
label: nls.localizeByDefault('Testing'),
settings: ['testing.*']
},
{
id: 'features.scm',
label: nls.localizeByDefault('Source Control'),
settings: ['scm.*']
},
{
id: 'features.extensions',
label: nls.localizeByDefault('Extensions'),
settings: ['extensions.*']
},
{
id: 'features.terminal',
label: nls.localizeByDefault('Terminal'),
settings: ['terminal.*']
},
{
id: 'features.task',
label: nls.localizeByDefault('Task'),
settings: ['task.*']
},
{
id: 'features.problems',
label: nls.localizeByDefault('Problems'),
settings: ['problems.*']
},
{
id: 'features.output',
label: nls.localizeByDefault('Output'),
settings: ['output.*']
},
{
id: 'features.comments',
label: nls.localizeByDefault('Comments'),
settings: ['comments.*']
},
{
id: 'features.remote',
label: nls.localizeByDefault('Remote'),
settings: ['remote.*']
},
{
id: 'features.timeline',
label: nls.localizeByDefault('Timeline'),
settings: ['timeline.*']
},
{
id: 'features.toolbar',
label: nls.localize('theia/preferences/toolbar', 'Toolbar'),
settings: ['toolbar.*']
},
{
id: 'features.notebook',
label: nls.localizeByDefault('Notebook'),
settings: ['notebook.*', 'interactiveWindow.*']
},
{
id: 'features.mergeEditor',
label: nls.localizeByDefault('Merge Editor'),
settings: ['mergeEditor.*']
},
{
id: 'features.chat',
label: nls.localizeByDefault('Chat'),
settings: ['chat.*', 'inlineChat.*']
}
]
},
{
id: 'application',
label: nls.localizeByDefault('Application'),
children: [
{
id: 'application.http',
label: nls.localizeByDefault('HTTP'),
settings: ['http.*']
},
{
id: 'application.keyboard',
label: nls.localizeByDefault('Keyboard'),
settings: ['keyboard.*']
},
{
id: 'application.update',
label: nls.localizeByDefault('Update'),
settings: ['update.*']
},
{
id: 'application.telemetry',
label: nls.localizeByDefault('Telemetry'),
settings: ['telemetry.*']
},
{
id: 'application.settingsSync',
label: nls.localizeByDefault('Settings Sync'),
settings: ['settingsSync.*']
},
{
id: 'application.experimental',
label: nls.localizeByDefault('Experimental'),
settings: ['application.experimental.*']
},
{
id: 'application.other',
label: nls.localizeByDefault('Other'),
settings: ['application.*']
}
]
},
{
id: 'security',
label: nls.localizeByDefault('Security'),
settings: ['security.*'],
children: [
{
id: 'security.workspace',
label: nls.localizeByDefault('Workspace'),
settings: ['security.workspace.*']
}
]
},
{
id: 'ai-features',
label: nls.localize('theia/preferences/ai-features', 'AI Features'),
children: [
{
id: 'ai-features.aiEnablement',
label: nls.localize('theia/preferences/ai-features/ai-enable', 'AI Enablement'),
settings: ['ai-features.AiEnable.*']
},
{
id: 'ai-features.anthropic',
label: 'Anthropic',
settings: ['ai-features.anthropic.*']
},
{
id: 'ai-features.chat',
label: nls.localizeByDefault('Chat'),
settings: ['ai-features.chat.*']
},
{
id: 'ai-features.codeCompletion',
label: nls.localize('theia/preferences/ai-features/code-completion', 'Code Completion'),
settings: ['ai-features.codeCompletion.*']
},
{
id: 'ai-features.huggingFace',
label: 'Hugging Face',
settings: ['ai-features.huggingFace.*']
},
{
id: 'ai-features.mcp',
label: nls.localize('theia/preferences/ai-features/MCP', 'MCP'),
settings: ['ai-features.mcp.*']
},
{
id: 'ai-features.modelSettings',
label: nls.localize('theia/preferences/ai-features/model-settings', 'Model Settings'),
settings: ['ai-features.modelSettings.*', 'ai-features.languageModelAliases']
},
{
id: 'ai-features.ollama',
label: 'Ollama',
settings: ['ai-features.ollama']
},
{
id: 'ai-features.llamafile',
label: 'Llamafile',
settings: ['ai-features.llamafile.*']
},
{
id: 'ai-features.openAiCustom',
label: nls.localize('theia/preferences/ai-features/open-ai-custom', '{0} Custom Models', 'Open AI'),
settings: ['ai-features.openAiCustom.*']
},
{
id: 'ai-features.openAiOfficial',
label: nls.localize('theia/preferences/ai-features/open-ai-official', '{0} Official Models', 'Open AI'),
settings: ['ai-features.openAiOfficial.*']
},
{
id: 'ai-features.promptTemplates',
label: nls.localize('theia/preferences/ai-features/promptTemplates', 'Prompt Templates'),
settings: ['ai-features.promptTemplates.*']
},
{
id: 'ai-features.SCANOSS',
label: 'SCANOSS',
settings: ['ai-features.SCANOSS.*']
},
{
id: 'ai-features.workspaceFunctions',
label: nls.localize('theia/preferences/ai-features/workspace-functions', 'Workspace Functions'),
settings: ['ai-features.workspaceFunctions.*']
}
]
},
{
id: 'extensions',
label: nls.localizeByDefault('Extensions'),
children: [
{
id: 'extensions.hosted-plugin',
label: nls.localize('theia/preferences/hostedPlugin', 'Hosted Plugin'),
settings: ['hosted-plugin.*']
}
]
}
];
@injectable()
export class PreferenceLayoutProvider {
getLayout(): PreferenceLayout[] {
return DEFAULT_LAYOUT;
}
getCommonlyUsedLayout(): PreferenceLayout {
return COMMONLY_USED_LAYOUT;
}
hasCategory(id: string): boolean {
return [...this.getLayout(), this.getCommonlyUsedLayout()].some(e => e.id === id);
}
getLayoutForPreference(preferenceId: string): PreferenceLayout | undefined {
const layout = this.getLayout();
for (const section of layout) {
const item = this.findItemInSection(section, preferenceId);
if (item) {
return item;
}
}
return undefined;
}
protected findItemInSection(section: PreferenceLayout, preferenceId: string): PreferenceLayout | undefined {
// First check whether any of its children match the preferenceId.
if (section.children) {
for (const child of section.children) {
const item = this.findItemInSection(child, preferenceId);
if (item) {
return item;
}
}
}
// Then check whether the section itself matches the preferenceId.
if (section.settings) {
for (const setting of section.settings) {
if (this.matchesSetting(preferenceId, setting)) {
return section;
}
}
}
return undefined;
}
protected matchesSetting(preferenceId: string, setting: string): boolean {
if (setting.includes('*')) {
return this.createRegExp(setting).test(preferenceId);
}
return preferenceId === setting;
}
protected createRegExp(setting: string): RegExp {
return new RegExp(`^${setting.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`);
}
}

View File

@@ -0,0 +1,75 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject } from '@theia/core/shared/inversify';
import { LabelProvider, codicon } from '@theia/core/lib/browser';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { CommandRegistry, MenuModelRegistry, Command, PreferenceScope } from '@theia/core/lib/common';
import { Preference, PreferenceMenus } from './preference-types';
/**
* @deprecated since 1.17.0 moved to PreferenceMenus namespace.
*/
export const FOLDER_SCOPE_MENU_PATH = PreferenceMenus.FOLDER_SCOPE_MENU_PATH;
/**
* @deprecated since 1.17.0. This work is now done in the PreferenceScopeTabbarWidget.
*/
@injectable()
export class PreferenceScopeCommandManager {
@inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry) protected readonly menuModelRegistry: MenuModelRegistry;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
protected foldersAsCommands: Command[] = [];
createFolderWorkspacesMenu(
folderWorkspaces: FileStat[],
currentFolderURI?: string,
): void {
this.foldersAsCommands.forEach(folderCommand => {
this.menuModelRegistry.unregisterMenuAction(folderCommand, FOLDER_SCOPE_MENU_PATH);
this.commandRegistry.unregisterCommand(folderCommand);
});
this.foldersAsCommands.length = 0;
folderWorkspaces.forEach(folderWorkspace => {
const folderLabel = this.labelProvider.getName(folderWorkspace.resource);
const iconClass = currentFolderURI === folderWorkspace.resource.toString() ? codicon('pass') : '';
const newFolderAsCommand = {
id: `preferenceScopeCommand:${folderWorkspace.resource.toString()}`,
label: folderLabel,
iconClass: iconClass
};
this.foldersAsCommands.push(newFolderAsCommand);
this.commandRegistry.registerCommand(newFolderAsCommand, {
isVisible: (callback, check) => check === 'from-tabbar',
isEnabled: (callback, check) => check === 'from-tabbar',
execute: (callback: (scopeDetails: Preference.SelectedScopeDetails) => void) => {
callback({ scope: PreferenceScope.Folder, uri: folderWorkspace.resource.toString(), activeScopeIsFolder: true });
}
});
this.menuModelRegistry.registerMenuAction(FOLDER_SCOPE_MENU_PATH, {
commandId: newFolderAsCommand.id,
label: newFolderAsCommand.label
});
});
}
}

View File

@@ -0,0 +1,278 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { CompositeTreeNode } from '@theia/core/lib/browser';
import { Emitter, OVERRIDE_PROPERTY_PATTERN, PreferenceConfigurations, PreferenceDataProperty, PreferenceSchemaService } from '@theia/core';
import debounce = require('@theia/core/shared/lodash.debounce');
import { Preference } from './preference-types';
import { COMMONLY_USED_SECTION_PREFIX, PreferenceLayoutProvider } from './preference-layout';
import { PreferenceTreeLabelProvider } from './preference-tree-label-provider';
export interface CreatePreferencesGroupOptions {
id: string,
group: string,
root: CompositeTreeNode,
expanded?: boolean,
depth?: number,
label?: string
}
@injectable()
export class PreferenceTreeGenerator {
@inject(PreferenceSchemaService) protected readonly schemaProvider: PreferenceSchemaService;
@inject(PreferenceConfigurations) protected readonly preferenceConfigs: PreferenceConfigurations;
@inject(PreferenceLayoutProvider) protected readonly layoutProvider: PreferenceLayoutProvider;
@inject(PreferenceTreeLabelProvider) protected readonly labelProvider: PreferenceTreeLabelProvider;
protected _root: CompositeTreeNode;
protected _idCache = new Map<string, string>();
protected readonly onSchemaChangedEmitter = new Emitter<CompositeTreeNode>();
readonly onSchemaChanged = this.onSchemaChangedEmitter.event;
protected readonly defaultTopLevelCategory = 'extensions';
get root(): CompositeTreeNode {
return this._root ?? this.generateTree();
}
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
this.schemaProvider.onDidChangeSchema(() => this.handleChangedSchema());
this.handleChangedSchema();
}
generateTree(): CompositeTreeNode {
this._idCache.clear();
const properties = this.schemaProvider.getSchemaProperties();
const groups = new Map<string, Preference.CompositeTreeNode>();
const root = this.createRootNode();
const commonlyUsedLayout = this.layoutProvider.getCommonlyUsedLayout();
const commonlyUsed = this.getOrCreatePreferencesGroup({
id: commonlyUsedLayout.id,
group: commonlyUsedLayout.id,
root,
groups,
label: commonlyUsedLayout.label
});
for (const layout of this.layoutProvider.getLayout()) {
this.getOrCreatePreferencesGroup({
id: layout.id,
group: layout.id,
root,
groups,
label: layout.label
});
}
for (const preference of commonlyUsedLayout.settings ?? []) {
if (properties.has(preference)) {
this.createLeafNode(preference, commonlyUsed, properties.get(preference)!);
}
}
for (const [propertyName, property] of properties.entries()) {
if (!property.hidden && !property.deprecationMessage && !this.preferenceConfigs.isSectionName(propertyName) && !OVERRIDE_PROPERTY_PATTERN.test(propertyName)) {
if (property.owner) {
this.createPluginLeafNode(propertyName, property, root, groups);
} else {
this.createBuiltinLeafNode(propertyName, property, root, groups);
}
}
}
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);
});
}
}
this._root = root;
return root;
};
protected createBuiltinLeafNode(name: string, property: PreferenceDataProperty, root: CompositeTreeNode, groups: Map<string, Preference.CompositeTreeNode>): void {
const { immediateParent, topLevelParent } = this.getParents(name, root, groups);
this.createLeafNode(name, immediateParent || topLevelParent, property);
}
protected createPluginLeafNode(name: string, property: PreferenceDataProperty, root: CompositeTreeNode, groups: Map<string, Preference.CompositeTreeNode>): void {
if (!property.owner) {
return;
}
const groupID = this.defaultTopLevelCategory;
const subgroupName = property.owner;
const subsubgroupName = property.group;
const hasGroup = Boolean(subsubgroupName);
const toplevelParent = this.getOrCreatePreferencesGroup({
id: groupID,
group: groupID,
root,
groups
});
const subgroupID = [groupID, subgroupName].join('.');
const subgroupParent = this.getOrCreatePreferencesGroup({
id: subgroupID,
group: groupID,
root: toplevelParent,
groups,
expanded: hasGroup,
label: subgroupName
});
const subsubgroupID = [groupID, subgroupName, subsubgroupName].join('.');
const subsubgroupParent = hasGroup ? this.getOrCreatePreferencesGroup({
id: subsubgroupID,
group: subgroupID,
root: subgroupParent,
groups,
depth: 2,
label: subsubgroupName
}) : undefined;
this.createLeafNode(name, subsubgroupParent || subgroupParent, property);
}
getNodeId(preferenceId: string): string {
return this._idCache.get(preferenceId) ?? '';
}
protected getParents(
name: string, root: CompositeTreeNode, groups: Map<string, Preference.CompositeTreeNode>
): {
topLevelParent: Preference.CompositeTreeNode, immediateParent: Preference.CompositeTreeNode | undefined
} {
const layoutItem = this.layoutProvider.getLayoutForPreference(name);
const labels = (layoutItem?.id ?? name).split('.');
const groupID = this.getGroupName(labels);
const subgroupName = groupID !== labels[0]
? labels[0]
// If a layout item is present, any additional segments are sections
// If not, then the name describes a leaf node and only non-final segments are sections.
: layoutItem || labels.length > 2
? labels.at(1)
: undefined;
const topLevelParent = this.getOrCreatePreferencesGroup({
id: groupID,
group: groupID,
root,
groups,
label: this.generateName(groupID)
});
const immediateParent = subgroupName ? this.getOrCreatePreferencesGroup({
id: [groupID, subgroupName].join('.'),
group: groupID,
root: topLevelParent,
groups,
label: layoutItem?.label ?? this.generateName(subgroupName)
}) : undefined;
return { immediateParent, topLevelParent };
}
protected getGroupName(labels: string[]): string {
const defaultGroup = labels[0];
if (this.layoutProvider.hasCategory(defaultGroup)) {
return defaultGroup;
}
return this.defaultTopLevelCategory;
}
protected getSubgroupName(labels: string[], computedGroupName: string): string | undefined {
if (computedGroupName !== labels[0]) {
return labels[0];
} else if (labels.length > 1) {
return labels[1];
} else {
return undefined;
}
}
protected generateName(id: string): string {
return this.labelProvider.formatString(id);
}
doHandleChangedSchema(): void {
const newTree = this.generateTree();
this.onSchemaChangedEmitter.fire(newTree);
}
handleChangedSchema = debounce(this.doHandleChangedSchema, 200);
protected createRootNode(): CompositeTreeNode {
return {
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: Preference.LeafNode = {
id: `${group}@${property}`,
preferenceId: property,
parent: preferencesGroup,
preference: { data },
depth: Preference.TreeNode.isTopLevel(preferencesGroup) ? 1 : 2
};
this._idCache.set(property, newNode.id);
CompositeTreeNode.addChild(preferencesGroup, newNode);
return newNode;
}
protected createPreferencesGroup(options: CreatePreferencesGroupOptions): Preference.CompositeTreeNode {
const newNode: Preference.CompositeTreeNode = {
id: `${options.group}@${options.id}`,
visible: true,
parent: options.root,
children: [],
expanded: false,
selected: false,
depth: 0,
label: options.label
};
const isTopLevel = Preference.TreeNode.isTopLevel(newNode);
if (!(options.expanded ?? isTopLevel)) {
delete newNode.expanded;
}
newNode.depth = options.depth ?? (isTopLevel ? 0 : 1);
CompositeTreeNode.addChild(options.root, newNode);
return newNode;
}
protected getOrCreatePreferencesGroup(options: CreatePreferencesGroupOptions & { groups: Map<string, Preference.CompositeTreeNode> }): Preference.CompositeTreeNode {
const existingGroup = options.groups.get(options.id);
if (existingGroup) { return existingGroup; }
const newNode = this.createPreferencesGroup(options);
options.groups.set(options.id, newNode);
return newNode;
};
}

View File

@@ -0,0 +1,110 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
const disableJSDOM = enableJSDOM();
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
FrontendApplicationConfigProvider.set({});
import { expect } from 'chai';
import { Container } from '@theia/core/shared/inversify';
import { PreferenceTreeGenerator } from './preference-tree-generator';
import { PreferenceTreeLabelProvider } from './preference-tree-label-provider';
import { Preference } from './preference-types';
import { SelectableTreeNode } from '@theia/core/lib/browser';
import { PreferenceLayoutProvider } from './preference-layout';
disableJSDOM();
describe('preference-tree-label-provider', () => {
let preferenceTreeLabelProvider: PreferenceTreeLabelProvider;
beforeEach(() => {
const container = new Container();
container.bind(PreferenceLayoutProvider).toSelf().inSingletonScope();
container.bind<any>(PreferenceTreeGenerator).toConstantValue({ getCustomLabelFor: () => { } });
preferenceTreeLabelProvider = container.resolve(PreferenceTreeLabelProvider);
});
it('PreferenceTreeLabelProvider.format', () => {
const testString = 'aaaBbbCcc Dddd eee';
expect(preferenceTreeLabelProvider['formatString'](testString)).eq('Aaa Bbb Ccc Dddd eee');
});
it('PreferenceTreeLabelProvider.format.Chinese', () => {
const testString = '某個設定/某个设定';
expect(preferenceTreeLabelProvider['formatString'](testString)).eq('某個設定/某个设定');
});
it('PreferenceTreeLabelProvider.format.Danish', () => {
const testString = 'indstillingPåEnØ';
expect(preferenceTreeLabelProvider['formatString'](testString)).eq('Indstilling På En Ø');
});
it('PreferenceTreeLabelProvider.format.Greek', () => {
const testString = 'κάποιαΡύθμιση';
expect(preferenceTreeLabelProvider['formatString'](testString)).eq('Κάποια Ρύθμιση');
});
it('PreferenceTreeLabelProvider.format.Russian', () => {
const testString = 'некоторыеНастройки';
expect(preferenceTreeLabelProvider['formatString'](testString)).eq('Некоторые Настройки');
});
it('PreferenceTreeLabelProvider.format.Armenian', () => {
const testString = 'ինչ-որՊարամետր';
expect(preferenceTreeLabelProvider['formatString'](testString)).eq('Ինչ-որ Պարամետր');
});
it('PreferenceTreeLabelProvider.format.specialCharacters', () => {
const testString = 'hyphenated-wordC++Setting';
expect(preferenceTreeLabelProvider['formatString'](testString)).eq('Hyphenated-word C++ Setting');
});
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';
testLeafName(property, expectedName);
});
it('when property constructs of two parts the second part is the leaf', () => {
const property = 'category-name.leaf';
const expectedName = 'Leaf';
testLeafName(property, expectedName);
});
function testLeafName(property: string, expectedName: string): void {
const expectedSelectableTreeNode: Preference.LeafNode & SelectableTreeNode = {
id: `group@${property}`,
parent: undefined,
visible: true,
selected: false,
depth: 2,
preferenceId: property,
preference: { data: {} }
};
expect(preferenceTreeLabelProvider['getName'](expectedSelectableTreeNode)).deep.eq(expectedName);
}
});
});

View File

@@ -0,0 +1,72 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { LabelProviderContribution, TreeNode } from '@theia/core/lib/browser';
import { Preference } from './preference-types';
import { PreferenceLayoutProvider } from './preference-layout';
@injectable()
export class PreferenceTreeLabelProvider implements LabelProviderContribution {
@inject(PreferenceLayoutProvider)
protected readonly layoutProvider: PreferenceLayoutProvider;
canHandle(element: object): number {
return TreeNode.is(element) && Preference.TreeNode.is(element) ? 150 : 0;
}
getName(node: Preference.TreeNode): string {
if (Preference.TreeNode.is(node) && node.label) {
return node.label;
}
const { id } = Preference.TreeNode.getGroupAndIdFromNodeId(node.id);
const labels = id.split('.');
const groupName = labels[labels.length - 1];
return this.formatString(groupName);
}
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;
}
}
formatString(string: string): string {
let formattedString = 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])) {
formattedString += ' ';
}
formattedString += string[i];
}
return formattedString.trim();
}
protected isUpperCase(char: string): boolean {
return char === char.toLocaleUpperCase() && char.toLocaleLowerCase() !== char.toLocaleUpperCase();
}
}

View File

@@ -0,0 +1,174 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import {
TreeNode as BaseTreeNode,
CompositeTreeNode as BaseCompositeTreeNode,
SelectableTreeNode,
CommonCommands,
} from '@theia/core/lib/browser';
import { Command, MenuPath, PreferenceDataProperty, PreferenceInspection, PreferenceScope } from '@theia/core';
import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
import { JsonType } from '@theia/core/lib/common/json-schema';
export namespace Preference {
export interface EditorCommandArgs {
id: string;
value: string | undefined;
}
export namespace EditorCommandArgs {
export function is(prefObject: EditorCommandArgs): prefObject is EditorCommandArgs {
return !!prefObject && 'id' in prefObject && 'value' in prefObject;
}
}
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, SelectableTreeNode {
expanded?: boolean;
depth: number;
label?: string;
}
export namespace CompositeTreeNode {
export const is = (node: TreeNode): node is CompositeTreeNode => !LeafNode.is(node);
}
export interface LeafNode extends BaseTreeNode {
label?: string;
depth: number;
preference: { data: PreferenceDataProperty };
preferenceId: string;
}
export namespace LeafNode {
export const is = (node: BaseTreeNode | LeafNode): node is LeafNode => 'preference' in node && !!node.preference.data;
export const getType = (node: BaseTreeNode | LeafNode): JsonType | undefined => is(node)
? Array.isArray(node.preference.data.type) ? node.preference.data.type[0] : node.preference.data.type
: undefined;
}
export const getValueInScope = <T extends JSONValue>(preferenceInfo: PreferenceInspection<T> | undefined, scope: number): T | undefined => {
if (!preferenceInfo) {
return undefined;
}
switch (scope) {
case PreferenceScope.User:
return preferenceInfo.globalValue;
case PreferenceScope.Workspace:
return preferenceInfo.workspaceValue;
case PreferenceScope.Folder:
return preferenceInfo.workspaceFolderValue;
default:
return undefined;
}
};
export interface SelectedScopeDetails {
scope: number;
uri: string | undefined;
activeScopeIsFolder: boolean;
};
export const DEFAULT_SCOPE: SelectedScopeDetails = {
scope: PreferenceScope.User,
uri: undefined,
activeScopeIsFolder: false
};
}
export namespace PreferencesCommands {
export const OPEN_PREFERENCES_JSON_TOOLBAR: Command = {
id: 'preferences:openJson.toolbar',
iconClass: 'codicon codicon-json'
};
export const COPY_JSON_NAME = Command.toDefaultLocalizedCommand({
id: 'preferences:copyJson.name',
label: 'Copy Setting ID'
});
export const RESET_PREFERENCE = Command.toDefaultLocalizedCommand({
id: 'preferences:reset',
label: 'Reset Setting'
});
export const COPY_JSON_VALUE = Command.toDefaultLocalizedCommand({
id: 'preferences:copyJson.value',
label: 'Copy Setting as JSON',
});
export const OPEN_USER_PREFERENCES = Command.toDefaultLocalizedCommand({
id: 'workbench.action.openGlobalSettings',
category: CommonCommands.PREFERENCES_CATEGORY,
label: 'Open User Settings',
});
export const OPEN_WORKSPACE_PREFERENCES = Command.toDefaultLocalizedCommand({
id: 'workbench.action.openWorkspaceSettings',
category: CommonCommands.PREFERENCES_CATEGORY,
label: 'Open Workspace Settings',
});
export const OPEN_FOLDER_PREFERENCES = Command.toDefaultLocalizedCommand({
id: 'workbench.action.openFolderSettings',
category: CommonCommands.PREFERENCES_CATEGORY,
label: 'Open Folder Settings'
});
export const OPEN_USER_PREFERENCES_JSON = Command.toDefaultLocalizedCommand({
id: 'workbench.action.openSettingsJson',
category: CommonCommands.PREFERENCES_CATEGORY,
label: 'Open Settings (JSON)'
});
export const OPEN_WORKSPACE_PREFERENCES_JSON = Command.toDefaultLocalizedCommand({
id: 'workbench.action.openWorkspaceSettingsFile',
category: CommonCommands.PREFERENCES_CATEGORY,
label: 'Open Workspace Settings (JSON)',
});
export const OPEN_FOLDER_PREFERENCES_JSON = Command.toDefaultLocalizedCommand({
id: 'workbench.action.openFolderSettingsFile',
category: CommonCommands.PREFERENCES_CATEGORY,
label: 'Open Folder Settings (JSON)',
});
}
export namespace PreferenceMenus {
export const PREFERENCE_EDITOR_CONTEXT_MENU: MenuPath = ['preferences:editor.contextMenu'];
export const PREFERENCE_EDITOR_COPY_ACTIONS: MenuPath = [...PREFERENCE_EDITOR_CONTEXT_MENU, 'preferences:editor.contextMenu.copy'];
export const FOLDER_SCOPE_MENU_PATH = ['preferences:scope.menu'];
}

View File

@@ -0,0 +1,174 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { codiconArray } from '@theia/core/lib/browser';
import { injectable, interfaces } from '@theia/core/shared/inversify';
import { IJSONSchema } from '@theia/core/lib/common/json-schema';
import { Preference } from '../../util/preference-types';
import { PreferenceLeafNodeRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
@injectable()
export class PreferenceArrayInputRenderer extends PreferenceLeafNodeRenderer<string[], HTMLInputElement> {
existingValues = new Map<string, { node: HTMLElement, index: number }>();
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(...codiconArray('close'));
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.spellcheck = false;
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', ...codiconArray('add'));
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');
return inputWrapper;
}
protected doHandleValueChange(): void {
this.updateInspection();
const values = this.getValue() ?? this.getDefaultValue();
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);
}
override dispose(): void {
this.existingValues.clear();
super.dispose();
}
}
@injectable()
export class PreferenceArrayInputRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-array-input-renderer';
id = PreferenceArrayInputRendererContribution.ID;
canHandleLeafNode(node: Preference.LeafNode): number {
const type = Preference.LeafNode.getType(node);
return type === 'array' && (node.preference.data.items as IJSONSchema)?.type === 'string' ? 2 : 0;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceArrayInputRenderer);
}
}

View File

@@ -0,0 +1,69 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, interfaces } from '@theia/core/shared/inversify';
import { Preference } from '../../util/preference-types';
import { PreferenceLeafNodeRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
@injectable()
export class PreferenceBooleanInputRenderer extends PreferenceLeafNodeRenderer<boolean, HTMLInputElement> {
protected createInteractable(parent: HTMLElement): void {
const interactable = document.createElement('input');
this.interactable = interactable;
interactable.type = 'checkbox';
interactable.classList.add('theia-input');
interactable.defaultChecked = Boolean(this.getValue());
interactable.onchange = this.handleUserInteraction.bind(this);
parent.appendChild(interactable);
}
protected override getAdditionalNodeClassnames(): Iterable<string> {
return ['boolean'];
}
protected getFallbackValue(): false {
return false;
}
protected handleUserInteraction(): Promise<void> {
return this.setPreferenceImmediately(this.interactable.checked);
}
protected doHandleValueChange(): void {
const currentValue = this.interactable.checked;
this.updateInspection();
const newValue = Boolean(this.getValue());
this.updateModificationStatus();
if (newValue !== currentValue && document.activeElement !== this.interactable) {
this.interactable.checked = newValue;
}
}
}
@injectable()
export class PreferenceBooleanInputRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-boolean-input-renderer';
id = PreferenceBooleanInputRendererContribution.ID;
canHandleLeafNode(node: Preference.LeafNode): number {
return Preference.LeafNode.getType(node) === 'boolean' ? 2 : 0;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceBooleanInputRenderer);
}
}

View File

@@ -0,0 +1,104 @@
// *****************************************************************************
// Copyright (C) 2022 EclipseSource 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { isObject } from '@theia/core/lib/common';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable, interfaces } from '@theia/core/shared/inversify';
import { OpenFileDialogProps } from '@theia/filesystem/lib/browser';
import { FileDialogService } from '@theia/filesystem/lib/browser/file-dialog/file-dialog-service';
import { WorkspaceCommands } from '@theia/workspace/lib/browser';
import { Preference } from '../../util/preference-types';
import { PreferenceNodeRenderer } from './preference-node-renderer';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
import { PreferenceStringInputRenderer } from './preference-string-input';
export interface FileNodeTypeDetails {
isFilepath: true;
selectionProps?: Partial<OpenFileDialogProps>;
}
export namespace FileNodeTypeDetails {
export function is(typeDetails: unknown): typeDetails is FileNodeTypeDetails {
return isObject<FileNodeTypeDetails>(typeDetails) && !!typeDetails.isFilepath;
}
}
@injectable()
export class PreferenceSingleFilePathInputRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-single-file-path-input-renderer';
id = PreferenceSingleFilePathInputRendererContribution.ID;
canHandleLeafNode(node: Preference.LeafNode): number {
const typeDetails = node.preference.data.typeDetails;
return FileNodeTypeDetails.is(typeDetails) && !typeDetails.selectionProps?.canSelectMany ? 5 : 0;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceSingleFilePathInputRenderer);
}
}
@injectable()
export class PreferenceSingleFilePathInputRenderer extends PreferenceStringInputRenderer {
@inject(FileDialogService) fileDialogService: FileDialogService;
get typeDetails(): FileNodeTypeDetails {
return this.preferenceNode.preference.data.typeDetails as FileNodeTypeDetails;
}
protected createInputWrapper(): HTMLElement {
const inputWrapper = document.createElement('div');
inputWrapper.classList.add('preference-file-container');
return inputWrapper;
}
protected override createInteractable(parent: HTMLElement): void {
const inputWrapper = this.createInputWrapper();
super.createInteractable(inputWrapper);
this.interactable.classList.add('preference-file-input');
this.createBrowseButton(inputWrapper);
parent.appendChild(inputWrapper);
}
protected createBrowseButton(parent: HTMLElement): void {
const button = document.createElement('button');
button.classList.add('theia-button', 'main', 'preference-file-button');
button.textContent = nls.localize('theia/core/file/browse', 'Browse');
const handler = this.browse.bind(this);
button.onclick = handler;
button.onkeydown = handler;
button.tabIndex = 0;
button.setAttribute('aria-label', 'Submit Preference Input');
parent.appendChild(button);
}
protected async browse(): Promise<void> {
const selectionProps = this.typeDetails.selectionProps;
const title = selectionProps?.title ?? selectionProps?.canSelectFolders ? WorkspaceCommands.OPEN_FOLDER.dialogLabel : WorkspaceCommands.OPEN_FILE.dialogLabel;
const selection = await this.fileDialogService.showOpenDialog({ title, ...selectionProps });
if (selection) {
this.setPreferenceImmediately(selection.path.fsPath());
}
}
protected override setPreferenceImmediately(value: string): Promise<void> {
this.interactable.value = value;
return super.setPreferenceImmediately(value);
}
}

View File

@@ -0,0 +1,78 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { PreferenceLeafNodeRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
import { injectable, inject, interfaces } from '@theia/core/shared/inversify';
import { CommandService, nls } from '@theia/core/lib/common';
import { Preference, PreferencesCommands } from '../../util/preference-types';
import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
@injectable()
export class PreferenceJSONLinkRenderer extends PreferenceLeafNodeRenderer<JSONValue, HTMLAnchorElement> {
@inject(CommandService) protected readonly commandService: CommandService;
protected createInteractable(parent: HTMLElement): void {
const message = nls.localizeByDefault('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);
}
}
@injectable()
export class PreferenceJSONLinkRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-json-link-renderer';
id = PreferenceJSONLinkRendererContribution.ID;
canHandleLeafNode(_node: Preference.LeafNode): number {
return 1;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceJSONLinkRenderer);
}
}

View File

@@ -0,0 +1,81 @@
// *****************************************************************************
// Copyright (C) 2023 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import { PreferenceTreeModel } from '../../preference-tree-model';
import { PreferenceTreeLabelProvider } from '../../util/preference-tree-label-provider';
import * as markdownit from '@theia/core/shared/markdown-it';
import * as markdownitemoji from '@theia/core/shared/markdown-it-emoji';
import { CommandRegistry } from '@theia/core';
@injectable()
export class PreferenceMarkdownRenderer {
@inject(PreferenceTreeModel)
protected readonly model: PreferenceTreeModel;
@inject(PreferenceTreeLabelProvider)
protected readonly labelProvider: PreferenceTreeLabelProvider;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
protected _renderer?: markdownit;
render(text: string): string {
return this.getRenderer().render(text);
}
renderInline(text: string): string {
return this.getRenderer().renderInline(text);
}
protected getRenderer(): markdownit {
this._renderer ??= this.buildMarkdownRenderer();
return this._renderer;
}
protected buildMarkdownRenderer(): markdownit {
const engine = markdownit().use(markdownitemoji.full);
const inlineCode = engine.renderer.rules.code_inline;
engine.renderer.rules.code_inline = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const content = token.content;
if (content.length > 2 && content.startsWith('#') && content.endsWith('#')) {
const id = content.substring(1, content.length - 1);
// First check whether there's a preference with the given ID
const preferenceNode = this.model.getNodeFromPreferenceId(id);
if (preferenceNode) {
let name = this.labelProvider.getName(preferenceNode);
const prefix = this.labelProvider.getPrefix(preferenceNode, true);
if (prefix) {
name = prefix + name;
}
return `<a title="${id}" href="preference:${id}">${name}</a>`;
}
// If no preference was found, check whether there's a command with the given ID
const command = this.commandRegistry.getCommand(id);
if (command) {
const name = `${command.category ? `${command.category}: ` : ''}${command.label}`;
return `<span class="command-link" title="${id}">${name}</span>`;
}
// If nothing was found, print a warning
console.warn(`Linked preference "${id}" not found.`);
}
return inlineCode ? inlineCode(tokens, idx, options, env, self) : '';
};
return engine;
}
}

View File

@@ -0,0 +1,141 @@
// *****************************************************************************
// Copyright (C) 2022 EclipseSource 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContributionProvider, Disposable, Emitter, Event, Prioritizeable } from '@theia/core';
import { inject, injectable, interfaces, named } from '@theia/core/shared/inversify';
import { Preference } from '../../util/preference-types';
import { PreferenceHeaderRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
export const PreferenceNodeRendererCreatorRegistry = Symbol('PreferenceNodeRendererCreatorRegistry');
export interface PreferenceNodeRendererCreatorRegistry {
registerPreferenceNodeRendererCreator(creator: PreferenceNodeRendererCreator): Disposable;
unregisterPreferenceNodeRendererCreator(creator: PreferenceNodeRendererCreator): void;
getPreferenceNodeRendererCreator(node: Preference.TreeNode): PreferenceNodeRendererCreator;
onDidChange: Event<void>;
}
export const PreferenceNodeRendererContribution = Symbol('PreferenceNodeRendererContribution');
export interface PreferenceNodeRendererContribution {
registerPreferenceNodeRendererCreator(registry: PreferenceNodeRendererCreatorRegistry): void;
}
export const PreferenceNodeRendererCreator = Symbol('PreferenceNodeRendererCreator');
export interface PreferenceNodeRendererCreator {
id: string;
canHandle(node: Preference.TreeNode): number;
createRenderer(node: Preference.TreeNode, container: interfaces.Container): PreferenceNodeRenderer;
}
@injectable()
export class DefaultPreferenceNodeRendererCreatorRegistry implements PreferenceNodeRendererCreatorRegistry {
protected readonly _creators: Map<string, PreferenceNodeRendererCreator> = new Map<string, PreferenceNodeRendererCreator>();
protected readonly onDidChangeEmitter = new Emitter<void>();
readonly onDidChange = this.onDidChangeEmitter.event;
constructor(
@inject(ContributionProvider) @named(PreferenceNodeRendererContribution)
protected readonly contributionProvider: ContributionProvider<PreferenceNodeRendererContribution>
) {
const contributions = this.contributionProvider.getContributions();
for (const contrib of contributions) {
contrib.registerPreferenceNodeRendererCreator(this);
}
}
registerPreferenceNodeRendererCreator(creator: PreferenceNodeRendererCreator): Disposable {
if (this._creators.has(creator.id)) {
console.warn(`A preference node renderer creator ${creator.id} is already registered.`);
return Disposable.NULL;
}
this._creators.set(creator.id, creator);
this.fireDidChange();
return Disposable.create(() => this._creators.delete(creator.id));
}
unregisterPreferenceNodeRendererCreator(creator: PreferenceNodeRendererCreator | string): void {
const id = typeof creator === 'string' ? creator : creator.id;
if (this._creators.delete(id)) {
this.fireDidChange();
}
}
getPreferenceNodeRendererCreator(node: Preference.TreeNode): PreferenceNodeRendererCreator {
const contributions = this.prioritize(node);
if (contributions.length >= 1) {
return contributions[0];
}
// we already bind a default creator contribution so if that happens it was deliberate
throw new Error(`There is no contribution for ${node.id}.`);
}
protected fireDidChange(): void {
this.onDidChangeEmitter.fire(undefined);
}
protected prioritize(node: Preference.TreeNode): PreferenceNodeRendererCreator[] {
const prioritized = Prioritizeable.prioritizeAllSync(Array.from(this._creators.values()), creator => {
try {
return creator.canHandle(node);
} catch {
return 0;
}
});
return prioritized.map(p => p.value);
}
}
@injectable()
export abstract class PreferenceLeafNodeRendererContribution implements PreferenceNodeRendererCreator, PreferenceNodeRendererContribution {
abstract id: string;
canHandle(node: Preference.TreeNode): number {
return Preference.LeafNode.is(node) ? this.canHandleLeafNode(node) : 0;
}
registerPreferenceNodeRendererCreator(registry: PreferenceNodeRendererCreatorRegistry): void {
registry.registerPreferenceNodeRendererCreator(this);
}
abstract canHandleLeafNode(node: Preference.LeafNode): number;
createRenderer(node: Preference.TreeNode, container: interfaces.Container): PreferenceNodeRenderer {
const child = container.createChild();
child.bind(Preference.Node).toConstantValue(node);
return this.createLeafNodeRenderer(child);
}
abstract createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer;
}
@injectable()
export class PreferenceHeaderRendererContribution implements PreferenceNodeRendererCreator, PreferenceNodeRendererContribution {
static ID = 'preference-header-renderer';
id = PreferenceHeaderRendererContribution.ID;
registerPreferenceNodeRendererCreator(registry: PreferenceNodeRendererCreatorRegistry): void {
registry.registerPreferenceNodeRendererCreator(this);
}
canHandle(node: Preference.TreeNode): number {
return !Preference.LeafNode.is(node) ? 1 : 0;
}
createRenderer(node: Preference.TreeNode, container: interfaces.Container): PreferenceNodeRenderer {
const grandchild = container.createChild();
grandchild.bind(Preference.Node).toConstantValue(node);
return grandchild.get(PreferenceHeaderRenderer);
}
}

View File

@@ -0,0 +1,508 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import {
ContextMenuRenderer, codicon, OpenerService, open
} 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, nls, PreferenceDataProperty, PreferenceInspection, PreferenceScope, PreferenceService } from '@theia/core/lib/common';
import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
import debounce = require('@theia/core/shared/lodash.debounce');
import { PreferenceTreeModel } from '../../preference-tree-model';
import { PreferencesSearchbarWidget } from '../preference-searchbar-widget';
import * as DOMPurify from '@theia/core/shared/dompurify';
import URI from '@theia/core/lib/common/uri';
import { PreferenceMarkdownRenderer } from './preference-markdown-renderer';
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;
schema?: PreferenceDataProperty;
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;
protected getAdditionalNodeClassnames(): Iterable<string> {
return [];
}
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<ValueType extends JSONValue, InteractableType extends HTMLElement>
extends PreferenceNodeRenderer
implements Required<GeneralPreferenceNodeRenderer> {
@inject(Preference.Node) protected override 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;
@inject(OpenerService) protected readonly openerService: OpenerService;
@inject(PreferenceMarkdownRenderer) protected readonly markdownRenderer: PreferenceMarkdownRenderer;
protected headlineWrapper: HTMLDivElement;
protected gutter: HTMLDivElement;
protected interactable: InteractableType;
protected inspection: PreferenceInspection<ValueType> | undefined;
protected isSet = false;
get schema(): PreferenceDataProperty {
return this.preferenceNode.preference.data;
}
@postConstruct()
protected override init(): void {
this.setId();
this.updateInspection();
this.domNode = this.createDomNode();
this.updateModificationStatus();
}
protected updateInspection(): void {
this.inspection = this.preferenceService.inspect<ValueType>(this.id, this.scopeTracker.currentScope.uri);
}
protected openLink(event: MouseEvent): void {
if (event.target instanceof HTMLAnchorElement) {
event.preventDefault();
event.stopPropagation();
// Exclude right click
if (event.button < 2) {
const uri = new URI(event.target.href);
open(this.openerService, uri);
}
}
}
protected createDomNode(): HTMLLIElement {
const wrapper = document.createElement('li');
wrapper.classList.add('single-pref');
wrapper.id = `${this.id}-editor`;
wrapper.tabIndex = 0;
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('settings-gear', true)} 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 = nls.localizeByDefault('More Actions...');
gutter.appendChild(cog);
const contentWrapper = document.createElement('div');
contentWrapper.classList.add('pref-content-container', ...this.getAdditionalNodeClassnames());
wrapper.appendChild(contentWrapper);
const { description, markdownDescription } = this.preferenceNode.preference.data;
if (markdownDescription || description) {
const descriptionWrapper = document.createElement('div');
descriptionWrapper.classList.add('pref-description');
if (markdownDescription) {
const renderedDescription = this.markdownRenderer.render(markdownDescription);
descriptionWrapper.onauxclick = this.openLink.bind(this);
descriptionWrapper.onclick = this.openLink.bind(this);
descriptionWrapper.oncontextmenu = () => false;
descriptionWrapper.innerHTML = DOMPurify.sanitize(renderedDescription, {
ALLOW_UNKNOWN_PROTOCOLS: true
});
} else if (description) {
descriptionWrapper.textContent = description;
}
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.getDefaultValue();
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 }],
context: target,
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(): void {
const wasSet = this.isSet;
const { inspection } = this;
const valueInCurrentScope = Preference.getValueInScope(inspection, this.scopeTracker.currentScope.scope);
this.isSet = valueInCurrentScope !== undefined;
if (wasSet !== this.isSet) {
this.gutter.classList.toggle('theia-mod-item-modified', this.isSet);
}
}
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 tags = this.schema.tags;
if (tags && tags.length > 0) {
const tagsWrapper = document.createElement('span');
tagsWrapper.classList.add('preference-leaf-headline-tags');
const PREVIEW_INDICATOR_DESCRIPTION = nls.localizeByDefault(
'Preview setting: this setting controls a new feature that is still under refinement yet ready to use. Feedback is welcome.');
const EXPERIMENTAL_INDICATOR_DESCRIPTION = nls.localizeByDefault(
'Experimental setting: this setting controls a new feature that is actively being developed and may be unstable. It is subject to change or removal.');
tags.forEach(tag => {
const tagElement = document.createElement('span');
const isExperimentalSetting = tag === 'experimental';
const isPreviewSetting = tag === 'preview';
tagElement.classList.add('preference-tag');
tagElement.textContent = isExperimentalSetting ? nls.localizeByDefault('Experimental') :
isPreviewSetting ? nls.localizeByDefault('Preview') : tag;
tagElement.title = isExperimentalSetting ? EXPERIMENTAL_INDICATOR_DESCRIPTION :
isPreviewSetting ? PREVIEW_INDICATOR_DESCRIPTION : tag;
tagsWrapper.appendChild(tagElement);
});
headlineWrapper.appendChild(tagsWrapper);
}
}
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.isSet ? nls.localizeByDefault('Also modified in') : nls.localizeByDefault('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) {
modifiedScopes.push(otherScope);
}
}
}
}
return modifiedScopes;
}
// Many preferences allow `null` and even use it as a default regardless of the declared type.
protected getValue(): ValueType | null {
let currentValue = Preference.getValueInScope(this.inspection, this.scopeTracker.currentScope.scope);
if (currentValue === undefined) {
currentValue = this.getDefaultValue();
}
return currentValue;
}
protected setPreferenceWithDebounce = debounce(this.setPreferenceImmediately.bind(this), 500, { leading: false, trailing: true });
protected setPreferenceImmediately(value: ValueType | undefined): Promise<void> {
return this.preferenceService.set(this.id, value, this.scopeTracker.currentScope.scope, this.scopeTracker.currentScope.uri)
.catch(() => this.handleValueChange());
}
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();
}
/**
* Returns the default value for this preference.
* @returns The default value from the inspection or the fallback value if no default is specified.
*/
protected getDefaultValue(): ValueType {
return this.inspection?.defaultValue ?? this.getFallbackValue();
}
/**
* Should create an HTML element that the user can interact with to change the value of the preference.
* @param container the parent element for the interactable. This method is responsible for adding the new element to its parent.
*/
protected abstract createInteractable(container: HTMLElement): void;
/**
* @returns a fallback default value for a preference of the type implemented by a concrete leaf renderer
* This function is only called if the default value for a given preference is not specified in its schema.
*/
protected abstract getFallbackValue(): ValueType;
/**
* This function is responsible for reconciling the display of the preference value with the value reported by the PreferenceService.
*/
protected abstract doHandleValueChange(): void;
}

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, interfaces } from '@theia/core/shared/inversify';
import { PreferenceLeafNodeRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
import { Preference } from '../../util/preference-types';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
@injectable()
/** For rendering preference items for which the only interesting feature is the description */
export class PreferenceNullInputRenderer extends PreferenceLeafNodeRenderer<null, HTMLElement> {
protected override createInteractable(container: HTMLElement): void {
const span = document.createElement('span');
this.interactable = span;
container.appendChild(span);
}
protected override getFallbackValue(): null {
// eslint-disable-next-line no-null/no-null
return null;
}
protected override doHandleValueChange(): void { }
}
@injectable()
export class PreferenceNullRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-null-renderer';
id = PreferenceNullRendererContribution.ID;
canHandleLeafNode(node: Preference.LeafNode): number {
const isOnlyNull = node.preference.data.type === 'null' || Array.isArray(node.preference.data.type) && node.preference.data.type.every(candidate => candidate === 'null');
return isOnlyNull ? 5 : 0;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceNullInputRenderer);
}
}

View File

@@ -0,0 +1,183 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { nls, isBoolean, isNumber } from '@theia/core';
import { injectable, interfaces } from '@theia/core/shared/inversify';
import { Preference } from '../../util/preference-types';
import { PreferenceLeafNodeRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
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<number, HTMLInputElement> {
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.step = this.preferenceNode.preference.data.type === 'integer' ? '1' : 'any';
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<void> {
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();
if (newValue !== currentValue) {
if (document.activeElement !== this.interactable) {
this.interactable.value = (newValue ?? this.getDefaultValue()).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: nls.localizeByDefault('Value must be a number.') };
}
if (data.type === 'integer' && !Number.isInteger(inputValue)) {
errorMessages.push(nls.localizeByDefault('Value must be an integer.'));
}
if (data.minimum !== undefined && isFinite(data.minimum)) {
// https://json-schema.org/understanding-json-schema/reference/numeric
// "In JSON Schema Draft 4, exclusiveMinimum and exclusiveMaximum work differently.
// There they are boolean values, that indicate whether minimum and maximum are exclusive of the value"
if (isBoolean(data.exclusiveMinimum) && data.exclusiveMinimum) {
if (inputValue <= data.minimum) {
errorMessages.push(nls.localizeByDefault('Value must be strictly greater than {0}.', data.minimum));
}
} else {
if (inputValue < data.minimum) {
errorMessages.push(nls.localizeByDefault('Value must be greater than or equal to {0}.', data.minimum));
}
}
}
if (data.maximum !== undefined && isFinite(data.maximum)) {
// https://json-schema.org/understanding-json-schema/reference/numeric
// "In JSON Schema Draft 4, exclusiveMinimum and exclusiveMaximum work differently.
// There they are boolean values, that indicate whether minimum and maximum are exclusive of the value"
if (isBoolean(data.exclusiveMaximum) && data.exclusiveMaximum) {
if (inputValue >= data.maximum) {
errorMessages.push(nls.localizeByDefault('Value must be strictly less than {0}.', data.maximum));
}
} else {
if (inputValue > data.maximum) {
errorMessages.push(nls.localizeByDefault('Value must be less than or equal to {0}.', data.maximum));
}
}
}
// Using JSON Schema before Draft 4 both exclusive and non-exclusive variants can be set
if (isNumber(data.exclusiveMinimum) && isFinite(data.exclusiveMinimum)) {
if (inputValue <= data.exclusiveMinimum) {
errorMessages.push(nls.localizeByDefault('Value must be strictly greater than {0}.', data.exclusiveMinimum));
}
}
if (isNumber(data.exclusiveMaximum) && isFinite(data.exclusiveMaximum)) {
if (inputValue >= data.exclusiveMaximum) {
errorMessages.push(nls.localizeByDefault('Value must be strictly less than {0}.', data.exclusiveMaximum));
}
}
if (isNumber(data.multipleOf) && data.multipleOf !== 0 && !Number.isInteger(inputValue / data.multipleOf)) {
errorMessages.push(nls.localizeByDefault('Value must be a multiple of {0}.', data.multipleOf));
}
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();
}
}
@injectable()
export class PreferenceNumberInputRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-number-input-renderer';
id = PreferenceNumberInputRendererContribution.ID;
canHandleLeafNode(node: Preference.LeafNode): number {
const type = Preference.LeafNode.getType(node);
return type === 'integer' || type === 'number' ? 2 : 0;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceNumberInputRenderer);
}
}

View File

@@ -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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { PreferenceLeafNodeRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
import { injectable, interfaces } from '@theia/core/shared/inversify';
import { JSONValue } from '@theia/core/shared/@lumino/coreutils';
import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component';
import { Preference } from '../../util/preference-types';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
import * as React from '@theia/core/shared/react';
import { createRoot } from '@theia/core/shared/react-dom/client';
import { escapeInvisibleChars } from '@theia/core/lib/common/strings';
import { PreferenceUtils } from '@theia/core';
@injectable()
export class PreferenceSelectInputRenderer extends PreferenceLeafNodeRenderer<JSONValue, HTMLDivElement> {
protected readonly selectComponent = React.createRef<SelectComponent>();
protected selectOptions: SelectOption[] = [];
protected get enumValues(): JSONValue[] {
return this.preferenceNode.preference.data.enum!;
}
protected updateSelectOptions(): void {
const updatedSelectOptions: SelectOption[] = [];
const values = this.enumValues;
const preferenceData = this.preferenceNode.preference.data;
const defaultValue = preferenceData.default;
for (let i = 0; i < values.length; i++) {
const value = values[i];
const stringValue = `${value}`;
const label = escapeInvisibleChars(preferenceData.enumItemLabels?.[i] ?? stringValue);
const detail = PreferenceUtils.deepEqual(defaultValue, value) ? 'default' : undefined;
let enumDescription = preferenceData.enumDescriptions?.[i];
let markdown = false;
const markdownEnumDescription = preferenceData.markdownEnumDescriptions?.[i];
if (markdownEnumDescription) {
enumDescription = this.markdownRenderer.renderInline(markdownEnumDescription);
markdown = true;
}
updatedSelectOptions.push({
label,
value: stringValue,
detail,
description: enumDescription,
markdown
});
}
this.selectOptions = updatedSelectOptions;
}
protected createInteractable(parent: HTMLElement): void {
this.updateSelectOptions();
const interactable = document.createElement('div');
const selectComponent = React.createElement(SelectComponent, {
options: this.selectOptions,
defaultValue: this.getDataValue(),
onChange: (_, index) => this.handleUserInteraction(index),
ref: this.selectComponent
});
this.interactable = interactable;
const root = createRoot(interactable);
root.render(selectComponent);
parent.appendChild(interactable);
}
protected getFallbackValue(): JSONValue {
const { default: schemaDefault, enum: enumValues } = this.preferenceNode.preference.data;
return schemaDefault !== undefined ? schemaDefault : enumValues![0];
}
protected doHandleValueChange(): void {
this.updateInspection();
this.updateSelectOptions();
const newValue = this.getDataValue();
this.updateModificationStatus();
if (document.activeElement !== this.interactable && this.selectComponent.current) {
this.selectComponent.current.value = newValue;
}
}
/**
* Returns the stringified index corresponding to the currently selected value.
*/
protected getDataValue(): number {
const currentValue = this.getValue();
let selected = this.enumValues.findIndex(value => PreferenceUtils.deepEqual(value, currentValue));
if (selected === -1) {
const fallback = this.getFallbackValue();
selected = this.enumValues.findIndex(value => PreferenceUtils.deepEqual(value, fallback));
}
return Math.max(selected, 0);
}
protected handleUserInteraction(selected: number): void {
const value = this.enumValues[selected];
this.setPreferenceImmediately(value);
}
}
@injectable()
export class PreferenceSelectInputRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-select-input-renderer';
id = PreferenceSelectInputRendererContribution.ID;
canHandleLeafNode(node: Preference.LeafNode): number {
return node.preference.data.enum ? 3 : 0;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceSelectInputRenderer);
}
}

View File

@@ -0,0 +1,76 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable, interfaces } from '@theia/core/shared/inversify';
import { Preference } from '../../util/preference-types';
import { PreferenceLeafNodeRenderer, PreferenceNodeRenderer } from './preference-node-renderer';
import { PreferenceLeafNodeRendererContribution } from './preference-node-renderer-creator';
@injectable()
export class PreferenceStringInputRenderer extends PreferenceLeafNodeRenderer<string, HTMLInputElement> {
protected createInteractable(parent: HTMLElement): void {
const interactable = document.createElement('input');
this.interactable = interactable;
interactable.type = 'text';
interactable.spellcheck = false;
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();
if (newValue !== currentValue) {
if (document.activeElement !== this.interactable) {
this.interactable.value = newValue ?? this.getDefaultValue();
} 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<void> {
await this.setPreferenceWithDebounce.flush();
this.handleValueChange();
}
}
@injectable()
export class PreferenceStringInputRendererContribution extends PreferenceLeafNodeRendererContribution {
static ID = 'preference-string-input-renderer';
id = PreferenceStringInputRendererContribution.ID;
canHandleLeafNode(node: Preference.LeafNode): number {
return Preference.LeafNode.getType(node) === 'string' ? 2 : 0;
}
createLeafNodeRenderer(container: interfaces.Container): PreferenceNodeRenderer {
return container.get(PreferenceStringInputRenderer);
}
}

View File

@@ -0,0 +1,376 @@
// *****************************************************************************
// 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-only 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 * as deepEqual from 'fast-deep-equal';
import {
CompositeTreeNode,
SelectableTreeNode,
StatefulWidget,
TopDownTreeIterator,
ExpandableTreeNode,
} from '@theia/core/lib/browser';
import { Disposable, DisposableCollection, PreferenceProviderDataChanges, PreferenceProviderProvider, PreferenceSchemaService, PreferenceService, unreachable } from '@theia/core';
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 { PreferencesScopeTabBar } from './preference-scope-tabbar-widget';
import { PreferenceNodeRendererCreatorRegistry } from './components/preference-node-renderer-creator';
import { COMMONLY_USED_SECTION_PREFIX } from '../util/preference-layout';
export interface PreferencesEditorState {
firstVisibleChildID: string,
}
@injectable()
export class PreferencesEditorWidget extends BaseWidget implements StatefulWidget {
static readonly ID = 'settings.editor';
static readonly LABEL = 'Settings Editor';
override 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<string, GeneralPreferenceNodeRenderer>();
protected preferenceDataKeys = new Map<string, string>();
// The commonly used section will duplicate preference ID's, so we'll keep a separate list of them.
protected commonlyUsedRenderers = new Map<string, GeneralPreferenceNodeRenderer>();
@inject(PreferenceService) protected readonly preferenceService: PreferenceService;
@inject(PreferenceTreeModel) protected readonly model: PreferenceTreeModel;
@inject(PreferenceNodeRendererFactory) protected readonly rendererFactory: PreferenceNodeRendererFactory;
@inject(PreferenceNodeRendererCreatorRegistry) protected readonly rendererRegistry: PreferenceNodeRendererCreatorRegistry;
@inject(PreferenceSchemaService) protected readonly schemaProvider: PreferenceSchemaService;
@inject(PreferencesScopeTabBar) protected readonly tabbar: PreferencesScopeTabBar;
@inject(PreferenceProviderProvider) protected readonly providerProvider: PreferenceProviderProvider;
@postConstruct()
protected init(): void {
this.doInit();
}
protected async doInit(): Promise<void> {
this.id = PreferencesEditorWidget.ID;
this.title.label = PreferencesEditorWidget.LABEL;
this.addClass('settings-main');
this.toDispose.pushAll([
this.subscribeToPreferenceProviderChanges(),
this.model.onFilterChanged(e => this.handleDisplayChange(e)),
this.model.onSelectionChanged(e => this.handleSelectionChange(e)),
]);
this.createContainers();
await this.preferenceService.ready;
this.handleDisplayChange({ source: PreferenceFilterChangeSource.Schema });
this.rendererRegistry.onDidChange(() => this.handleRegistryChange());
}
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 subscribeToPreferenceProviderChanges(): Disposable {
const res = new DisposableCollection();
for (const scope of this.schemaProvider.validScopes) {
const provider = this.providerProvider(scope);
if (!provider) { continue; }
provider.onDidPreferencesChanged(e => this.handlePreferenceChanges(e), this, res);
}
return res;
}
protected handleDisplayChange(e: PreferenceFilterChangeEvent): void {
const { isFiltered } = this.model;
const currentFirstVisible = this.firstVisibleChildID;
const leavesAreVisible = this.areLeavesVisible();
if (e.source === PreferenceFilterChangeSource.Search) {
this.handleSearchChange(isFiltered, leavesAreVisible);
} else if (e.source === PreferenceFilterChangeSource.Scope) {
this.handleScopeChange(isFiltered);
this.showInTree(currentFirstVisible);
} else if (e.source === PreferenceFilterChangeSource.Schema) {
this.handleSchemaChange(isFiltered);
this.showInTree(currentFirstVisible);
} else {
unreachable(e.source, 'Not all PreferenceFilterChangeSource enum variants handled.');
}
this.resetScroll(currentFirstVisible, e.source === PreferenceFilterChangeSource.Search);
}
protected handleRegistryChange(): void {
for (const [id, renderer, collection] of this.allRenderers()) {
renderer.dispose();
collection.delete(id);
}
this.handleDisplayChange({ source: PreferenceFilterChangeSource.Schema });
}
protected handleSchemaChange(isFiltered: boolean): void {
for (const [id, renderer, collection] of this.allRenderers()) {
const node = this.model.getNode(renderer.nodeId);
if (!node || (Preference.LeafNode.is(node) && this.hasSchemaChanged(renderer, node))) {
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 hasSchemaChanged(renderer: GeneralPreferenceNodeRenderer, node: Preference.LeafNode): boolean {
return !deepEqual(renderer.schema, node.preference.data);
}
protected handleSearchChange(isFiltered: boolean, leavesAreVisible: boolean): void {
if (leavesAreVisible) {
for (const [, renderer] of this.allRenderers()) {
const isHidden = this.hideIfFailsFilters(renderer, isFiltered);
if (!isHidden) {
renderer.handleSearchChange?.(isFiltered);
}
}
}
}
protected areLeavesVisible(): boolean {
const leavesAreVisible = this.model.totalVisibleLeaves > 0;
this.node.classList.toggle('no-results', !leavesAreVisible);
this.scrollContainer.classList.toggle('hidden', !leavesAreVisible);
return leavesAreVisible;
}
protected *allRenderers(): IterableIterator<[string, GeneralPreferenceNodeRenderer, Map<string, GeneralPreferenceNodeRenderer>]> {
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: PreferenceProviderDataChanges): 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, filterWasModified: boolean = false): void {
requestAnimationFrame(() => {
this.scrollBar?.update();
if (filterWasModified) {
this.scrollContainer.scrollTop = 0;
} else if (nodeIDToScrollTo) {
const { id, collection } = this.analyzeIDAndGetRendererGroup(nodeIDToScrollTo);
const renderer = collection.get(id);
if (renderer?.visible) {
this.scrollContainer.scrollTo(0, renderer.node.offsetTop);
return;
}
}
});
};
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 shouldUpdateModelSelection = true;
protected setFirstVisibleChildID(id?: string): void {
if (id && id !== this.firstVisibleChildID) {
this.firstVisibleChildID = id;
if (!this.shouldUpdateModelSelection) { return; }
this.showInTree(id);
}
}
protected showInTree(id: string): void {
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<SelectableTreeNode>[]): 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;
this.scrollWithoutModelUpdate(renderer.node);
return;
}
}
} else {
const { id, collection } = this.analyzeIDAndGetRendererGroup(node.id);
const renderer = collection.get(id);
this.scrollWithoutModelUpdate(renderer?.node);
}
}
}
/** Ensures that we don't set the model's selection while attempting to scroll in reaction to a model selection change. */
protected scrollWithoutModelUpdate(node?: HTMLElement): void {
if (!node) { return; }
this.shouldUpdateModelSelection = false;
this.scrollContainer.scrollTo(0, node.offsetTop);
requestAnimationFrame(() => this.shouldUpdateModelSelection = true);
}
protected analyzeIDAndGetRendererGroup(nodeID: string): { id: string, group: string, collection: Map<string, GeneralPreferenceNodeRenderer> } {
const { id, group } = Preference.TreeNode.getGroupAndIdFromNodeId(nodeID);
const collection = group === COMMONLY_USED_SECTION_PREFIX ? this.commonlyUsedRenderers : this.renderers;
return { id, group, collection };
}
protected override getScrollContainer(): HTMLElement {
return this.scrollContainer;
}
storeState(): PreferencesEditorState {
return {
firstVisibleChildID: this.firstVisibleChildID,
};
}
restoreState(oldState: PreferencesEditorState): void {
this.firstVisibleChildID = oldState.firstVisibleChildID;
this.resetScroll(this.firstVisibleChildID);
}
}

View File

@@ -0,0 +1,345 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { TabBar, Widget, Title } from '@theia/core/shared/@lumino/widgets';
import { Message, ContextMenuRenderer, LabelProvider, StatefulWidget, codicon } from '@theia/core/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import URI from '@theia/core/lib/common/uri';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { PreferenceScopeCommandManager } from '../util/preference-scope-command-manager';
import { Preference, PreferenceMenus } from '../util/preference-types';
import { CommandRegistry, DisposableCollection, Emitter, MenuModelRegistry, PreferenceScope } from '@theia/core/lib/common';
import { nls } from '@theia/core/lib/common/nls';
const USER_TAB_LABEL = nls.localizeByDefault('User');
const USER_TAB_INDEX = PreferenceScope['User'];
const WORKSPACE_TAB_LABEL = nls.localizeByDefault('Workspace');
const WORKSPACE_TAB_INDEX = PreferenceScope['Workspace'];
const FOLDER_TAB_LABEL = nls.localizeByDefault('Folder');
const FOLDER_TAB_INDEX = PreferenceScope['Folder'];
const PREFERENCE_TAB_CLASSNAME = 'preferences-scope-tab';
const GENERAL_FOLDER_TAB_CLASSNAME = 'preference-folder';
const LABELED_FOLDER_TAB_CLASSNAME = 'preferences-folder-tab';
const FOLDER_DROPDOWN_CLASSNAME = 'preferences-folder-dropdown';
const FOLDER_DROPDOWN_ICON_CLASSNAME = 'preferences-folder-dropdown-icon ' + codicon('chevron-down');
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;
}
@injectable()
export class PreferencesScopeTabBar extends TabBar<Widget> implements StatefulWidget {
static ID = 'preferences-scope-tab-bar';
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(PreferenceScopeCommandManager) protected readonly preferencesMenuFactory: PreferenceScopeCommandManager;
@inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer;
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
@inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry) protected readonly menuModelRegistry: MenuModelRegistry;
protected readonly onScopeChangedEmitter = new Emitter<Preference.SelectedScopeDetails>();
readonly onScopeChanged = this.onScopeChangedEmitter.event;
protected toDispose = new DisposableCollection();
protected folderTitle: Title<Widget>;
protected currentWorkspaceRoots: FileStat[] = [];
protected currentSelection: Preference.SelectedScopeDetails = Preference.DEFAULT_SCOPE;
protected editorScrollAtTop = true;
get currentScope(): Preference.SelectedScopeDetails {
return this.currentSelection;
}
protected setNewScopeSelection(newSelection: Preference.SelectedScopeDetails): void {
const 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) {
this.addOrUpdateFolderTab();
}
this.emitNewScope();
}
}
@postConstruct()
protected init(): void {
this.id = PreferencesScopeTabBar.ID;
this.setupInitialDisplay();
this.tabActivateRequested.connect((sender, args) => {
const scopeDetails = this.toScopeDetails(args.title);
if (scopeDetails) {
this.setNewScopeSelection(scopeDetails);
}
});
this.toDispose.pushAll([
this.workspaceService.onWorkspaceChanged(newRoots => this.doUpdateDisplay(newRoots)),
this.workspaceService.onWorkspaceLocationChanged(() => this.doUpdateDisplay(this.workspaceService.tryGetRoots())),
]);
const tabUnderline = document.createElement('div');
tabUnderline.className = TABBAR_UNDERLINE_CLASSNAME;
this.node.append(tabUnderline);
}
protected toScopeDetails(title?: Title<Widget> | 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) {
this.addWorkspaceTab(this.workspaceService.workspace);
}
this.addOrUpdateFolderTab();
}
protected override onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
this.addTabIndexToTabs();
}
protected addTabIndexToTabs(): void {
this.node.querySelectorAll('li').forEach((tab, index) => {
tab.tabIndex = 0;
const handler = () => {
if (tab.className.includes(GENERAL_FOLDER_TAB_CLASSNAME) && this.currentWorkspaceRoots.length > 1) {
const tabRect = tab.getBoundingClientRect();
this.openContextMenu(tabRect, tab, 'keypress');
} else {
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.toString() },
label: USER_TAB_LABEL,
owner: this,
className: PREFERENCE_TAB_CLASSNAME
}));
}
protected addWorkspaceTab(currentWorkspace: FileStat): Title<Widget> {
const scopeDetails = this.getWorkspaceDataset(currentWorkspace);
const workspaceTabTitle = new Title({
dataset: this.toDataSet(scopeDetails),
label: WORKSPACE_TAB_LABEL,
owner: this,
className: PREFERENCE_TAB_CLASSNAME,
});
this.addTab(workspaceTabTitle);
return workspaceTabTitle;
}
protected getWorkspaceDataset(currentWorkspace: FileStat): Preference.SelectedScopeDetails {
const { resource, isDirectory } = currentWorkspace;
const scope = WORKSPACE_TAB_INDEX;
return { uri: resource.toString(), activeScopeIsFolder: isDirectory, scope };
}
protected addOrUpdateFolderTab(): void {
if (!!this.workspaceService.workspace) {
this.currentWorkspaceRoots = this.workspaceService.tryGetRoots();
const multipleFolderRootsAreAvailable = this.currentWorkspaceRoots && this.currentWorkspaceRoots.length > 1;
const noFolderRootsAreAvailable = this.currentWorkspaceRoots.length === 0;
const shouldShowFoldersSeparately = this.workspaceService.saved;
if (!noFolderRootsAreAvailable) {
if (!this.folderTitle) {
this.folderTitle = new Title({
label: '',
caption: FOLDER_TAB_LABEL,
owner: this,
});
}
this.setFolderTitleProperties(multipleFolderRootsAreAvailable);
if (multipleFolderRootsAreAvailable || shouldShowFoldersSeparately) {
this.addTab(this.folderTitle);
}
} else {
const folderTabIndex = this.titles.findIndex(title => title.caption === FOLDER_TAB_LABEL);
if (folderTabIndex > -1) {
this.removeTabAt(folderTabIndex);
}
}
}
}
protected setFolderTitleProperties(multipleFolderRootsAreAvailable: boolean): void {
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.toDataSet(this.currentSelection);
this.folderTitle.className = multipleFolderRootsAreAvailable ? SELECTED_FOLDER_DROPDOWN_CLASSNAME : SINGLE_FOLDER_TAB_CLASSNAME;
} else {
const singleFolderRoot = this.currentWorkspaceRoots[0].resource;
const singleFolderLabel = this.labelProvider.getName(singleFolderRoot);
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.toString(), uri: defaultURI };
}
}
protected folderSelectionCallback = (newScope: Preference.SelectedScopeDetails): void => { this.setNewScopeSelection(newScope); };
protected getFolderContextMenu(workspaceRoots = this.workspaceService.tryGetRoots()): void {
this.preferencesMenuFactory.createFolderWorkspacesMenu(workspaceRoots, this.currentSelection.uri);
}
override handleEvent(): void {
// Don't - the handlers are defined in PreferenceScopeTabbarWidget.addTabIndexToTabs()
}
protected openContextMenu(tabRect: DOMRect | ClientRect, folderTabNode: HTMLElement, source: 'click' | 'keypress'): void {
const toDisposeOnHide = new DisposableCollection();
for (const root of this.workspaceService.tryGetRoots()) {
const id = `set-scope-to-${root.resource.toString()}`;
toDisposeOnHide.pushAll([
this.commandRegistry.registerCommand(
{ id },
{ execute: () => this.setScope(root.resource) }
),
this.menuModelRegistry.registerMenuAction(PreferenceMenus.FOLDER_SCOPE_MENU_PATH,
{
commandId: id,
label: this.labelProvider.getName(root),
}
)
]);
}
this.contextMenuRenderer.render({
menuPath: PreferenceMenus.FOLDER_SCOPE_MENU_PATH,
anchor: { x: tabRect.left, y: tabRect.bottom },
context: folderTabNode,
onHide: () => {
setTimeout(() => toDisposeOnHide.dispose());
if (source === 'click') { folderTabNode.blur(); }
}
});
}
protected doUpdateDisplay(newRoots: FileStat[]): void {
const folderWasRemoved = newRoots.length < this.currentWorkspaceRoots.length;
this.currentWorkspaceRoots = newRoots;
if (folderWasRemoved) {
const removedFolderWasSelectedScope = !this.currentWorkspaceRoots.some(root => root.resource.toString() === this.currentSelection.uri);
if (removedFolderWasSelectedScope) {
this.setNewScopeSelection(Preference.DEFAULT_SCOPE);
}
}
this.updateWorkspaceTab();
this.addOrUpdateFolderTab();
}
protected updateWorkspaceTab(): void {
const currentWorkspace = this.workspaceService.workspace;
if (currentWorkspace) {
const workspaceTitle = this.titles.find(title => title.label === WORKSPACE_TAB_LABEL) ?? this.addWorkspaceTab(currentWorkspace);
const scopeDetails = this.getWorkspaceDataset(currentWorkspace);
workspaceTitle.dataset = this.toDataSet(scopeDetails);
if (this.currentSelection.scope === PreferenceScope.Workspace) {
this.setNewScopeSelection(scopeDetails);
}
}
}
protected emitNewScope(): void {
this.onScopeChangedEmitter.fire(this.currentSelection);
}
setScope(scope: PreferenceScope.User | PreferenceScope.Workspace | URI): void {
const details = scope instanceof URI ? this.getDetailsForResource(scope) : this.getDetailsForScope(scope);
if (details) {
this.setNewScopeSelection(details);
}
}
protected getDetailsForScope(scope: PreferenceScope.User | PreferenceScope.Workspace): Preference.SelectedScopeDetails | undefined {
const stringifiedSelectionScope = scope.toString();
const correspondingTitle = this.titles.find(title => title.dataset.scope === stringifiedSelectionScope);
return this.toScopeDetails(correspondingTitle);
}
protected getDetailsForResource(resource: URI): Preference.SelectedScopeDetails | undefined {
const parent = this.workspaceService.getWorkspaceRootUri(resource);
if (!parent) {
return undefined;
}
if (!this.workspaceService.isMultiRootWorkspaceOpened) {
return this.getDetailsForScope(PreferenceScope.Workspace);
}
return ({ scope: PreferenceScope.Folder, uri: parent.toString(), activeScopeIsFolder: true });
}
storeState(): PreferencesScopeTabBarState {
return {
scopeDetails: this.currentScope
};
}
restoreState(oldState: PreferencesScopeTabBarState): void {
const scopeDetails = this.toScopeDetails(oldState.scopeDetails);
if (scopeDetails) {
this.setNewScopeSelection(scopeDetails);
}
}
toggleShadow(showShadow: boolean): void {
this.toggleClass(SHADOW_CLASSNAME, showShadow);
}
override dispose(): void {
super.dispose();
this.toDispose.dispose();
}
}

View File

@@ -0,0 +1,183 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { codicon, ReactWidget, StatefulWidget, Widget } from '@theia/core/lib/browser';
import { injectable, postConstruct, unmanaged } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import debounce = require('p-debounce');
import { Emitter } from '@theia/core';
import { nls } from '@theia/core/lib/common/nls';
export interface PreferencesSearchbarState {
searchTerm: string;
}
@injectable()
export class PreferencesSearchbarWidget extends ReactWidget implements StatefulWidget {
static readonly ID = 'settings.header';
static readonly LABEL = 'Settings Header';
static readonly SEARCHBAR_ID = 'preference-searchbar';
protected readonly onFilterStringChangedEmitter = new Emitter<string>();
readonly onFilterChanged = this.onFilterStringChangedEmitter.event;
protected searchbarRef: React.RefObject<HTMLInputElement> = React.createRef<HTMLInputElement>();
protected resultsCount: number = 0;
constructor(@unmanaged() options?: Widget.IOptions) {
super(options);
this.focus = this.focus.bind(this);
}
@postConstruct()
protected init(): void {
this.id = PreferencesSearchbarWidget.ID;
this.title.label = PreferencesSearchbarWidget.LABEL;
this.update();
}
protected handleSearch = (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => this.search(e.target.value);
protected search = debounce(async (value: string) => {
this.onFilterStringChangedEmitter.fire(value);
this.update();
}, 200);
focus(): void {
if (this.searchbarRef.current) {
this.searchbarRef.current.focus();
}
}
/**
* Clears the search input and all search results.
* @param e on-click mouse event.
*/
protected clearSearchResults = async (e: React.MouseEvent): Promise<void> => {
const search = document.getElementById(PreferencesSearchbarWidget.SEARCHBAR_ID) as HTMLInputElement;
if (search) {
search.value = '';
await this.search(search.value);
this.update();
}
};
/**
* Renders all search bar options.
*/
protected renderOptionContainer(): React.ReactNode {
const resultsCount = this.renderResultsCountOption();
const clearAllOption = this.renderClearAllOption();
return <div className="option-buttons"> {resultsCount} {clearAllOption} </div>;
}
/**
* Renders a badge displaying search results count.
*/
protected renderResultsCountOption(): React.ReactNode {
let resultsFound: string;
if (this.resultsCount === 0) {
resultsFound = nls.localizeByDefault('No Settings Found');
} else if (this.resultsCount === 1) {
resultsFound = nls.localizeByDefault('1 Setting Found');
} else {
resultsFound = nls.localizeByDefault('{0} Settings Found', this.resultsCount.toFixed(0));
}
return this.searchTermExists() ?
(<span
className="results-found"
title={resultsFound}>
{resultsFound}
</span>)
: '';
}
/**
* Renders a clear all button.
*/
protected renderClearAllOption(): React.ReactNode {
return <span
className={`${codicon('clear-all')} option ${(this.searchTermExists() ? 'enabled' : '')}`}
title={nls.localizeByDefault('Clear Search Results')}
onClick={this.clearSearchResults}
/>;
}
/**
* Determines whether the search input currently has a value.
* @returns true, if the search input currently has a value; false, otherwise.
*/
protected searchTermExists(): boolean {
return !!this.searchbarRef.current?.value;
}
protected getSearchTerm(): string {
const search = document.getElementById(PreferencesSearchbarWidget.SEARCHBAR_ID) as HTMLInputElement;
return search?.value;
}
async updateSearchTerm(searchTerm: string): Promise<void> {
const search = document.getElementById(PreferencesSearchbarWidget.SEARCHBAR_ID) as HTMLInputElement;
if (!search || search.value === searchTerm) {
return;
}
search.value = searchTerm;
await this.search(search.value);
this.update();
}
render(): React.ReactNode {
const optionContainer = this.renderOptionContainer();
return (
<div className='settings-header'>
<div className="settings-search-container" ref={this.focus}>
<input
type="text"
id={PreferencesSearchbarWidget.SEARCHBAR_ID}
spellCheck={false}
placeholder={nls.localizeByDefault('Search settings')}
className="settings-search-input theia-input"
onChange={this.handleSearch}
ref={this.searchbarRef}
/>
{optionContainer}
</div>
</div >
);
}
/**
* Updates the search result count.
* @param count the result count.
*/
updateResultsCount(count: number): void {
this.resultsCount = count;
this.update();
}
storeState(): PreferencesSearchbarState {
return {
searchTerm: this.getSearchTerm()
};
}
restoreState(oldState: PreferencesSearchbarState): void {
const searchInputExists = this.onDidChangeVisibility(() => {
this.updateSearchTerm(oldState.searchTerm || '');
searchInputExists.dispose();
});
}
}

View File

@@ -0,0 +1,102 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import {
ExpandableTreeNode,
TreeNode,
TreeProps,
TreeWidget,
TREE_NODE_CONTENT_CLASS,
} from '@theia/core/lib/browser';
import React = require('@theia/core/shared/react');
import { PreferenceTreeModel, PreferenceTreeNodeRow, PreferenceTreeNodeProps } from '../preference-tree-model';
import { Preference } from '../util/preference-types';
@injectable()
export class PreferencesTreeWidget extends TreeWidget {
static ID = 'preferences.tree';
protected shouldFireSelectionEvents: boolean = true;
protected firstVisibleLeafNodeID: string;
@inject(PreferenceTreeModel) override readonly model: PreferenceTreeModel;
@inject(TreeProps) protected readonly treeProps: TreeProps;
@postConstruct()
override init(): void {
super.init();
this.id = PreferencesTreeWidget.ID;
this.toDispose.pushAll([
this.model.onFilterChanged(() => {
this.updateRows();
}),
]);
}
override doUpdateRows(): void {
this.rows = new Map();
let index = 0;
for (const [id, nodeRow] of this.model.currentRows.entries()) {
if (nodeRow.visibleChildren > 0 && this.isVisibleNode(nodeRow.node)) {
this.rows.set(id, { ...nodeRow, index: index++ });
}
}
this.updateScrollToRow();
}
protected isVisibleNode(node: Preference.TreeNode): boolean {
if (Preference.TreeNode.isTopLevel(node)) {
return true;
} else {
return ExpandableTreeNode.isExpanded(node.parent) && Preference.TreeNode.is(node.parent) && this.isVisibleNode(node.parent);
}
}
protected override doRenderNodeRow({ depth, visibleChildren, node, isExpansible }: PreferenceTreeNodeRow): React.ReactNode {
return this.renderNode(node, { depth, visibleChildren, isExpansible });
}
protected override renderNode(node: TreeNode, props: PreferenceTreeNodeProps): React.ReactNode {
if (!TreeNode.isVisible(node)) {
return undefined;
}
const attributes = this.createNodeAttributes(node, props);
const content = <div className={TREE_NODE_CONTENT_CLASS}>
{this.renderExpansionToggle(node, props)}
{this.renderCaption(node, props)}
</div>;
return React.createElement('div', attributes, content);
}
protected override renderExpansionToggle(node: TreeNode, props: PreferenceTreeNodeProps): React.ReactNode {
if (ExpandableTreeNode.is(node) && !props.isExpansible) {
return <div className='preferences-tree-spacer' />;
}
return super.renderExpansionToggle(node, props);
}
protected override 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;
}
}

View File

@@ -0,0 +1,105 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { createTreeContainer, LabelProviderContribution, WidgetFactory } from '@theia/core/lib/browser';
import { bindContributionProvider } from '@theia/core/lib/common/contribution-provider';
import { Container, interfaces } from '@theia/core/shared/inversify';
import { PreferenceTreeModel } from '../preference-tree-model';
import { PreferenceTreeLabelProvider } from '../util/preference-tree-label-provider';
import { Preference } from '../util/preference-types';
import { PreferenceArrayInputRenderer, PreferenceArrayInputRendererContribution } from './components/preference-array-input';
import { PreferenceBooleanInputRenderer, PreferenceBooleanInputRendererContribution } from './components/preference-boolean-input';
import { PreferenceSingleFilePathInputRenderer, PreferenceSingleFilePathInputRendererContribution } from './components/preference-file-input';
import { PreferenceJSONLinkRenderer, PreferenceJSONLinkRendererContribution } from './components/preference-json-input';
import { PreferenceHeaderRenderer, PreferenceNodeRendererFactory } from './components/preference-node-renderer';
import {
DefaultPreferenceNodeRendererCreatorRegistry, PreferenceHeaderRendererContribution, PreferenceNodeRendererContribution, PreferenceNodeRendererCreatorRegistry
} from './components/preference-node-renderer-creator';
import { PreferenceNumberInputRenderer, PreferenceNumberInputRendererContribution } from './components/preference-number-input';
import { PreferenceSelectInputRenderer, PreferenceSelectInputRendererContribution } from './components/preference-select-input';
import { PreferenceStringInputRenderer, PreferenceStringInputRendererContribution } from './components/preference-string-input';
import { PreferenceMarkdownRenderer } from './components/preference-markdown-renderer';
import { PreferencesEditorWidget } from './preference-editor-widget';
import { PreferencesScopeTabBar } from './preference-scope-tabbar-widget';
import { PreferencesSearchbarWidget } from './preference-searchbar-widget';
import { PreferencesTreeWidget } from './preference-tree-widget';
import { PreferencesWidget } from './preference-widget';
import { PreferenceNullInputRenderer, PreferenceNullRendererContribution } from './components/preference-null-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();
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: PreferencesWidget.ID,
createWidget: () => container.get(PreferencesWidget)
})).inSingletonScope();
bindContributionProvider(bind, PreferenceNodeRendererContribution);
bind(PreferenceSelectInputRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceSelectInputRendererContribution).inSingletonScope();
bind(PreferenceArrayInputRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceArrayInputRendererContribution).inSingletonScope();
bind(PreferenceStringInputRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceStringInputRendererContribution).inSingletonScope();
bind(PreferenceNullInputRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceNullRendererContribution).inSingletonScope();
bind(PreferenceBooleanInputRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceBooleanInputRendererContribution).inSingletonScope();
bind(PreferenceNumberInputRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceNumberInputRendererContribution).inSingletonScope();
bind(PreferenceJSONLinkRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceJSONLinkRendererContribution).inSingletonScope();
bind(PreferenceHeaderRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceHeaderRendererContribution).inSingletonScope();
bind(PreferenceSingleFilePathInputRenderer).toSelf();
bind(PreferenceNodeRendererContribution).to(PreferenceSingleFilePathInputRendererContribution).inSingletonScope();
bind(DefaultPreferenceNodeRendererCreatorRegistry).toSelf().inSingletonScope();
bind(PreferenceNodeRendererCreatorRegistry).toService(DefaultPreferenceNodeRendererCreatorRegistry);
}
export function createPreferencesWidgetContainer(parent: interfaces.Container): Container {
const child = createTreeContainer(parent, {
model: PreferenceTreeModel,
widget: PreferencesTreeWidget,
props: { search: false }
});
child.bind(PreferencesEditorWidget).toSelf();
child.bind(PreferencesSearchbarWidget).toSelf();
child.bind(PreferencesScopeTabBar).toSelf();
child.bind(PreferencesWidget).toSelf();
child.bind(PreferenceNodeRendererFactory).toFactory(({ container }) => (node: Preference.TreeNode) => {
const registry = container.get<PreferenceNodeRendererCreatorRegistry>(PreferenceNodeRendererCreatorRegistry);
const creator = registry.getPreferenceNodeRendererCreator(node);
return creator.createRenderer(node, container);
});
child.bind(PreferenceMarkdownRenderer).toSelf().inSingletonScope();
return child;
}

View File

@@ -0,0 +1,119 @@
// *****************************************************************************
// 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { postConstruct, injectable, inject } from '@theia/core/shared/inversify';
import { Panel, Widget, Message, StatefulWidget, codicon } from '@theia/core/lib/browser';
import { PreferencesEditorState, PreferencesEditorWidget } from './preference-editor-widget';
import { PreferencesTreeWidget } from './preference-tree-widget';
import { PreferencesSearchbarState, PreferencesSearchbarWidget } from './preference-searchbar-widget';
import { PreferencesScopeTabBar, PreferencesScopeTabBarState } from './preference-scope-tabbar-widget';
import { Preference } from '../util/preference-types';
import URI from '@theia/core/lib/common/uri';
import { nls } from '@theia/core/lib/common/nls';
import { PreferenceScope } from '@theia/core';
interface PreferencesWidgetState {
scopeTabBarState: PreferencesScopeTabBarState,
editorState: PreferencesEditorState,
searchbarWidgetState: PreferencesSearchbarState,
}
@injectable()
export class PreferencesWidget extends Panel implements StatefulWidget {
/**
* The widget `id`.
*/
static readonly ID = 'settings_widget';
/**
* The widget `label` which is used for display purposes.
*/
static readonly LABEL = nls.localizeByDefault('Settings');
@inject(PreferencesEditorWidget) protected readonly editorWidget: PreferencesEditorWidget;
@inject(PreferencesTreeWidget) protected readonly treeWidget: PreferencesTreeWidget;
@inject(PreferencesSearchbarWidget) protected readonly searchbarWidget: PreferencesSearchbarWidget;
@inject(PreferencesScopeTabBar) protected readonly tabBarWidget: PreferencesScopeTabBar;
get currentScope(): Preference.SelectedScopeDetails {
return this.tabBarWidget.currentScope;
}
setSearchTerm(query: string): Promise<void> {
return this.searchbarWidget.updateSearchTerm(query);
}
setScope(scope: PreferenceScope.User | PreferenceScope.Workspace | URI): void {
this.tabBarWidget.setScope(scope);
}
protected override onResize(msg: Widget.ResizeMessage): void {
super.onResize(msg);
if (msg.width < 600 && this.treeWidget && !this.treeWidget.isHidden) {
this.treeWidget.hide();
this.editorWidget.addClass('full-pane');
} else if (msg.width >= 600 && this.treeWidget && this.treeWidget.isHidden) {
this.treeWidget.show();
this.editorWidget.removeClass('full-pane');
}
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.searchbarWidget.focus();
}
@postConstruct()
protected init(): void {
this.id = PreferencesWidget.ID;
this.title.label = PreferencesWidget.LABEL;
this.title.caption = PreferencesWidget.LABEL;
this.title.closable = true;
this.addClass('theia-settings-container');
this.title.iconClass = codicon('settings');
this.searchbarWidget.addClass('preferences-searchbar-widget');
this.addWidget(this.searchbarWidget);
this.tabBarWidget.addClass('preferences-tabbar-widget');
this.addWidget(this.tabBarWidget);
this.treeWidget.addClass('preferences-tree-widget');
this.addWidget(this.treeWidget);
this.editorWidget.addClass('preferences-editor-widget');
this.addWidget(this.editorWidget);
this.update();
}
getPreviewNode(): Node | undefined {
return this.node;
}
storeState(): PreferencesWidgetState {
return {
scopeTabBarState: this.tabBarWidget.storeState(),
editorState: this.editorWidget.storeState(),
searchbarWidgetState: this.searchbarWidget.storeState(),
};
}
restoreState(state: PreferencesWidgetState): void {
this.tabBarWidget.restoreState(state.scopeTabBarState);
this.editorWidget.restoreState(state.editorState);
this.searchbarWidget.restoreState(state.searchbarWidgetState);
}
}

View File

@@ -0,0 +1,100 @@
// *****************************************************************************
// Copyright (C) 2019 TypeFox 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-only WITH Classpath-exception-2.0
// *****************************************************************************
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { PreferenceScope } from '@theia/core/lib/common/preferences';
import { WorkspaceService, WorkspaceData } from '@theia/workspace/lib/browser/workspace-service';
import { AbstractResourcePreferenceProvider } from '../common/abstract-resource-preference-provider';
@injectable()
export class WorkspaceFilePreferenceProviderOptions {
workspaceUri: URI;
}
export const WorkspaceFilePreferenceProviderFactory = Symbol('WorkspaceFilePreferenceProviderFactory');
export type WorkspaceFilePreferenceProviderFactory = (options: WorkspaceFilePreferenceProviderOptions) => WorkspaceFilePreferenceProvider;
@injectable()
export class WorkspaceFilePreferenceProvider extends AbstractResourcePreferenceProvider {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(WorkspaceFilePreferenceProviderOptions)
protected readonly options: WorkspaceFilePreferenceProviderOptions;
protected sectionsInsideSettings = new Set<string>();
protected getUri(): URI {
return this.options.workspaceUri;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override parse(content: string): any {
const data = super.parse(content);
if (WorkspaceData.is(data)) {
const settings = { ...data.settings };
for (const key of this.configurations.getSectionNames().filter(name => name !== 'settings')) {
// If the user has written configuration inside the "settings" object, we will respect that.
if (settings[key]) {
this.sectionsInsideSettings.add(key);
}
// Favor sections outside the "settings" object to agree with VSCode behavior
if (data[key]) {
settings[key] = data[key];
this.sectionsInsideSettings.delete(key);
}
}
return settings;
}
return {};
}
protected override getPath(preferenceName: string): string[] {
const firstSegment = preferenceName.split('.', 1)[0];
const remainder = preferenceName.slice(firstSegment.length + 1);
if (this.belongsInSection(firstSegment, remainder)) {
// Default to writing sections outside the "settings" object.
const path = [firstSegment];
if (remainder) {
path.push(remainder);
}
// If the user has already written this section inside the "settings" object, modify it there.
if (this.sectionsInsideSettings.has(firstSegment)) {
path.unshift('settings');
}
return path;
}
return ['settings'].concat(super.getPath(preferenceName) ?? []);
}
/**
* @returns `true` if `firstSegment` is a section name (e.g. `tasks`, `launch`)
*/
protected belongsInSection(firstSegment: string, remainder: string): boolean {
return this.configurations.isSectionName(firstSegment);
}
getScope(): PreferenceScope {
return PreferenceScope.Workspace;
}
override getDomain(): string[] {
// workspace file is treated as part of the workspace
return this.workspaceService.tryGetRoots().map(r => r.resource.toString()).concat([this.options.workspaceUri.toString()]);
}
}

View File

@@ -0,0 +1,153 @@
// *****************************************************************************
// Copyright (C) 2018 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-only WITH Classpath-exception-2.0
// *****************************************************************************
/* eslint-disable @typescript-eslint/no-explicit-any */
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { WorkspaceFilePreferenceProviderFactory, WorkspaceFilePreferenceProvider } from './workspace-file-preference-provider';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { Emitter, Event, PreferenceProvider, PreferenceProviderDataChanges, PreferenceProviderProvider, PreferenceScope } from '@theia/core';
import { JSONObject } from '@theia/core/shared/@lumino/coreutils';
@injectable()
export class WorkspacePreferenceProvider implements PreferenceProvider {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(WorkspaceFilePreferenceProviderFactory)
protected readonly workspaceFileProviderFactory: WorkspaceFilePreferenceProviderFactory;
@inject(PreferenceProviderProvider)
protected readonly preferenceProviderProvider: PreferenceProviderProvider;
protected readonly onDidPreferencesChangedEmitter = new Emitter<PreferenceProviderDataChanges>();
readonly onDidPreferencesChanged: Event<PreferenceProviderDataChanges> = this.onDidPreferencesChangedEmitter.event;
protected readonly toDisposeOnEnsureDelegateUpToDate = new DisposableCollection();
protected _ready = new Deferred<void>();
readonly ready = this._ready.promise;
protected readonly disposables = new DisposableCollection();
@postConstruct()
protected init(): void {
this.workspaceService.ready.then(() => {
// If there is no workspace after the workspace service is initialized, then no more work is needed for this provider to be ready.
// If there is a workspace, then we wait for the new delegate to be ready before declaring this provider ready.
if (!this.workspaceService.workspace) {
this._ready.resolve();
} else {
// important for the case if onWorkspaceLocationChanged has fired before this init is finished
this.ensureDelegateUpToDate();
}
});
}
dispose(): void {
this.disposables.dispose();
}
canHandleScope(scope: PreferenceScope): boolean {
return true;
}
getConfigUri(resourceUri: string | undefined = this.ensureResourceUri(), sectionName?: string): URI | undefined {
return this.delegate?.getConfigUri && this.delegate?.getConfigUri(resourceUri, sectionName);
}
getContainingConfigUri(resourceUri: string | undefined = this.ensureResourceUri(), sectionName?: string): URI | undefined {
return this.delegate?.getContainingConfigUri?.(resourceUri, sectionName);
}
protected _delegate: PreferenceProvider | undefined;
protected get delegate(): PreferenceProvider | undefined {
return this._delegate;
}
protected ensureDelegateUpToDate(): void {
const delegate = this.createDelegate();
if (this._delegate !== delegate) {
this.toDisposeOnEnsureDelegateUpToDate.dispose();
this.disposables.push(this.toDisposeOnEnsureDelegateUpToDate);
this._delegate = delegate;
if (delegate) {
// If this provider has not yet declared itself ready, it should do so when the new delegate is ready.
delegate.ready.then(() => this._ready.resolve(), () => { });
}
if (delegate instanceof WorkspaceFilePreferenceProvider) {
this.toDisposeOnEnsureDelegateUpToDate.pushAll([
delegate,
delegate.onDidPreferencesChanged(changes => this.onDidPreferencesChangedEmitter.fire(changes))
]);
}
}
}
protected createDelegate(): PreferenceProvider | undefined {
const workspace = this.workspaceService.workspace;
if (!workspace) {
return undefined;
}
if (!this.workspaceService.isMultiRootWorkspaceOpened) {
return this.preferenceProviderProvider(PreferenceScope.Folder);
}
if (this._delegate instanceof WorkspaceFilePreferenceProvider && this._delegate.getConfigUri().isEqual(workspace.resource)) {
return this._delegate;
}
return this.workspaceFileProviderFactory({
workspaceUri: workspace.resource
});
}
get<T>(preferenceName: string, resourceUri: string | undefined = this.ensureResourceUri()): T | undefined {
const delegate = this.delegate;
return delegate ? delegate.get<T>(preferenceName, resourceUri) : undefined;
}
resolve<T>(preferenceName: string, resourceUri: string | undefined = this.ensureResourceUri()): { value?: T, configUri?: URI } {
const delegate = this.delegate;
return delegate ? delegate.resolve<T>(preferenceName, resourceUri) : {};
}
async setPreference(preferenceName: string, value: any, resourceUri: string | undefined = this.ensureResourceUri()): Promise<boolean> {
const delegate = this.delegate;
if (delegate) {
return delegate.setPreference(preferenceName, value, resourceUri);
}
return false;
}
getPreferences(resourceUri: string | undefined = this.ensureResourceUri()): JSONObject {
const delegate = this.delegate;
return delegate ? delegate.getPreferences(resourceUri) : {};
}
protected ensureResourceUri(): string | undefined {
if (this.workspaceService.workspace && !this.workspaceService.isMultiRootWorkspaceOpened) {
return this.workspaceService.workspace.resource.toString();
}
return undefined;
}
}