Path: blob/main/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as DOM from '../../../../base/browser/dom.js';6import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';7import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js';8import { BaseActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';9import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js';10import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';11import { HistoryInputBox, IHistoryInputOptions } from '../../../../base/browser/ui/inputbox/inputBox.js';12import { Widget } from '../../../../base/browser/ui/widget.js';13import { Action, IAction } from '../../../../base/common/actions.js';14import { Emitter, Event } from '../../../../base/common/event.js';15import { MarkdownString } from '../../../../base/common/htmlContent.js';16import { KeyCode } from '../../../../base/common/keyCodes.js';17import { Disposable } from '../../../../base/common/lifecycle.js';18import { Schemas } from '../../../../base/common/network.js';19import { isEqual } from '../../../../base/common/resources.js';20import { ThemeIcon } from '../../../../base/common/themables.js';21import { URI } from '../../../../base/common/uri.js';22import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js';23import { IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js';24import { ILanguageService } from '../../../../editor/common/languages/language.js';25import { IModelDeltaDecoration, TrackedRangeStickiness } from '../../../../editor/common/model.js';26import { localize } from '../../../../nls.js';27import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js';28import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';29import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js';30import { ContextScopedHistoryInputBox } from '../../../../platform/history/browser/contextScopedHistoryWidget.js';31import { showHistoryKeybindingHint } from '../../../../platform/history/browser/historyWidgetKeybindingHint.js';32import { IHoverService } from '../../../../platform/hover/browser/hover.js';33import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';34import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';35import { ILabelService } from '../../../../platform/label/common/label.js';36import { asCssVariable, badgeBackground, badgeForeground, contrastBorder } from '../../../../platform/theme/common/colorRegistry.js';37import { isWorkspaceFolder, IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';38import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';39import { settingsEditIcon, settingsScopeDropDownIcon } from './preferencesIcons.js';4041export class FolderSettingsActionViewItem extends BaseActionViewItem {4243private _folder: IWorkspaceFolder | null;44private _folderSettingCounts = new Map<string, number>();4546private container!: HTMLElement;47private anchorElement!: HTMLElement;48private anchorElementHover!: IManagedHover;49private labelElement!: HTMLElement;50private detailsElement!: HTMLElement;51private dropDownElement!: HTMLElement;5253constructor(54action: IAction,55@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,56@IContextMenuService private readonly contextMenuService: IContextMenuService,57@IHoverService private readonly hoverService: IHoverService,58) {59super(null, action);60const workspace = this.contextService.getWorkspace();61this._folder = workspace.folders.length === 1 ? workspace.folders[0] : null;62this._register(this.contextService.onDidChangeWorkspaceFolders(() => this.onWorkspaceFoldersChanged()));63}6465get folder(): IWorkspaceFolder | null {66return this._folder;67}6869set folder(folder: IWorkspaceFolder | null) {70this._folder = folder;71this.update();72}7374setCount(settingsTarget: URI, count: number): void {75const workspaceFolder = this.contextService.getWorkspaceFolder(settingsTarget);76if (!workspaceFolder) {77throw new Error('unknown folder');78}79const folder = workspaceFolder.uri;80this._folderSettingCounts.set(folder.toString(), count);81this.update();82}8384override render(container: HTMLElement): void {85this.element = container;8687this.container = container;88this.labelElement = DOM.$('.action-title');89this.detailsElement = DOM.$('.action-details');90this.dropDownElement = DOM.$('.dropdown-icon.hide' + ThemeIcon.asCSSSelector(settingsScopeDropDownIcon));91this.anchorElement = DOM.$('a.action-label.folder-settings', {92role: 'button',93'aria-haspopup': 'true',94'tabindex': '0'95}, this.labelElement, this.detailsElement, this.dropDownElement);96this.anchorElementHover = this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.anchorElement, ''));97this._register(DOM.addDisposableListener(this.anchorElement, DOM.EventType.MOUSE_DOWN, e => DOM.EventHelper.stop(e)));98this._register(DOM.addDisposableListener(this.anchorElement, DOM.EventType.CLICK, e => this.onClick(e)));99this._register(DOM.addDisposableListener(this.container, DOM.EventType.KEY_UP, e => this.onKeyUp(e)));100101DOM.append(this.container, this.anchorElement);102103this.update();104}105106private onKeyUp(event: KeyboardEvent): void {107const keyboardEvent = new StandardKeyboardEvent(event);108switch (keyboardEvent.keyCode) {109case KeyCode.Enter:110case KeyCode.Space:111this.onClick(event);112return;113}114}115116override onClick(event: DOM.EventLike): void {117DOM.EventHelper.stop(event, true);118if (!this.folder || this._action.checked) {119this.showMenu();120} else {121this._action.run(this._folder);122}123}124125protected override updateEnabled(): void {126this.update();127}128129protected override updateChecked(): void {130this.update();131}132133private onWorkspaceFoldersChanged(): void {134const oldFolder = this._folder;135const workspace = this.contextService.getWorkspace();136if (oldFolder) {137this._folder = workspace.folders.filter(folder => isEqual(folder.uri, oldFolder.uri))[0] || workspace.folders[0];138}139this._folder = this._folder ? this._folder : workspace.folders.length === 1 ? workspace.folders[0] : null;140141this.update();142143if (this._action.checked) {144this._action.run(this._folder);145}146}147148private update(): void {149let total = 0;150this._folderSettingCounts.forEach(n => total += n);151152const workspace = this.contextService.getWorkspace();153if (this._folder) {154this.labelElement.textContent = this._folder.name;155this.anchorElementHover.update(this._folder.name);156const detailsText = this.labelWithCount(this._action.label, total);157this.detailsElement.textContent = detailsText;158this.dropDownElement.classList.toggle('hide', workspace.folders.length === 1 || !this._action.checked);159} else {160const labelText = this.labelWithCount(this._action.label, total);161this.labelElement.textContent = labelText;162this.detailsElement.textContent = '';163this.anchorElementHover.update(this._action.label);164this.dropDownElement.classList.remove('hide');165}166167this.anchorElement.classList.toggle('checked', this._action.checked);168this.container.classList.toggle('disabled', !this._action.enabled);169}170171private showMenu(): void {172this.contextMenuService.showContextMenu({173getAnchor: () => this.container,174getActions: () => this.getDropdownMenuActions(),175getActionViewItem: () => undefined,176onHide: () => {177this.anchorElement.blur();178}179});180}181182private getDropdownMenuActions(): IAction[] {183const actions: IAction[] = [];184const workspaceFolders = this.contextService.getWorkspace().folders;185if (this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE && workspaceFolders.length > 0) {186actions.push(...workspaceFolders.map((folder, index) => {187const folderCount = this._folderSettingCounts.get(folder.uri.toString());188return {189id: 'folderSettingsTarget' + index,190label: this.labelWithCount(folder.name, folderCount),191tooltip: this.labelWithCount(folder.name, folderCount),192checked: !!this.folder && isEqual(this.folder.uri, folder.uri),193enabled: true,194class: undefined,195run: () => this._action.run(folder)196};197}));198}199return actions;200}201202private labelWithCount(label: string, count: number | undefined): string {203// Append the count if it's >0 and not undefined204if (count) {205label += ` (${count})`;206}207208return label;209}210}211212export type SettingsTarget = ConfigurationTarget.APPLICATION | ConfigurationTarget.USER_LOCAL | ConfigurationTarget.USER_REMOTE | ConfigurationTarget.WORKSPACE | URI;213214export interface ISettingsTargetsWidgetOptions {215enableRemoteSettings?: boolean;216}217218export class SettingsTargetsWidget extends Widget {219220private settingsSwitcherBar!: ActionBar;221private userLocalSettings!: Action;222private userRemoteSettings!: Action;223private workspaceSettings!: Action;224private folderSettingsAction!: Action;225private folderSettings!: FolderSettingsActionViewItem;226private options: ISettingsTargetsWidgetOptions;227228private _settingsTarget: SettingsTarget | null = null;229230private readonly _onDidTargetChange = this._register(new Emitter<SettingsTarget>());231readonly onDidTargetChange: Event<SettingsTarget> = this._onDidTargetChange.event;232233constructor(234parent: HTMLElement,235options: ISettingsTargetsWidgetOptions | undefined,236@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,237@IInstantiationService private readonly instantiationService: IInstantiationService,238@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,239@ILabelService private readonly labelService: ILabelService,240@ILanguageService private readonly languageService: ILanguageService241) {242super();243this.options = options ?? {};244this.create(parent);245this._register(this.contextService.onDidChangeWorkbenchState(() => this.onWorkbenchStateChanged()));246this._register(this.contextService.onDidChangeWorkspaceFolders(() => this.update()));247}248249private resetLabels() {250const remoteAuthority = this.environmentService.remoteAuthority;251const hostLabel = remoteAuthority && this.labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority);252this.userLocalSettings.label = localize('userSettings', "User");253this.userRemoteSettings.label = localize('userSettingsRemote', "Remote") + (hostLabel ? ` [${hostLabel}]` : '');254this.workspaceSettings.label = localize('workspaceSettings', "Workspace");255this.folderSettingsAction.label = localize('folderSettings', "Folder");256}257258private create(parent: HTMLElement): void {259const settingsTabsWidget = DOM.append(parent, DOM.$('.settings-tabs-widget'));260this.settingsSwitcherBar = this._register(new ActionBar(settingsTabsWidget, {261orientation: ActionsOrientation.HORIZONTAL,262focusOnlyEnabledItems: true,263ariaLabel: localize('settingsSwitcherBarAriaLabel', "Settings Switcher"),264ariaRole: 'tablist',265actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => action.id === 'folderSettings' ? this.folderSettings : undefined266}));267268this.userLocalSettings = this._register(new Action('userSettings', '', '.settings-tab', true, () => this.updateTarget(ConfigurationTarget.USER_LOCAL)));269this.userLocalSettings.tooltip = localize('userSettings', "User");270271this.userRemoteSettings = this._register(new Action('userSettingsRemote', '', '.settings-tab', true, () => this.updateTarget(ConfigurationTarget.USER_REMOTE)));272const remoteAuthority = this.environmentService.remoteAuthority;273const hostLabel = remoteAuthority && this.labelService.getHostLabel(Schemas.vscodeRemote, remoteAuthority);274this.userRemoteSettings.tooltip = localize('userSettingsRemote', "Remote") + (hostLabel ? ` [${hostLabel}]` : '');275276this.workspaceSettings = this._register(new Action('workspaceSettings', '', '.settings-tab', false, () => this.updateTarget(ConfigurationTarget.WORKSPACE)));277278this.folderSettingsAction = this._register(new Action('folderSettings', '', '.settings-tab', false, async folder => {279this.updateTarget(isWorkspaceFolder(folder) ? folder.uri : ConfigurationTarget.USER_LOCAL);280}));281this.folderSettings = this._register(this.instantiationService.createInstance(FolderSettingsActionViewItem, this.folderSettingsAction));282283this.resetLabels();284this.update();285286this.settingsSwitcherBar.push([this.userLocalSettings, this.userRemoteSettings, this.workspaceSettings, this.folderSettingsAction]);287}288289get settingsTarget(): SettingsTarget | null {290return this._settingsTarget;291}292293set settingsTarget(settingsTarget: SettingsTarget | null) {294this._settingsTarget = settingsTarget;295this.userLocalSettings.checked = ConfigurationTarget.USER_LOCAL === this.settingsTarget;296this.userRemoteSettings.checked = ConfigurationTarget.USER_REMOTE === this.settingsTarget;297this.workspaceSettings.checked = ConfigurationTarget.WORKSPACE === this.settingsTarget;298if (this.settingsTarget instanceof URI) {299this.folderSettings.action.checked = true;300this.folderSettings.folder = this.contextService.getWorkspaceFolder(this.settingsTarget as URI);301} else {302this.folderSettings.action.checked = false;303}304}305306setResultCount(settingsTarget: SettingsTarget, count: number): void {307if (settingsTarget === ConfigurationTarget.WORKSPACE) {308let label = localize('workspaceSettings', "Workspace");309if (count) {310label += ` (${count})`;311}312313this.workspaceSettings.label = label;314} else if (settingsTarget === ConfigurationTarget.USER_LOCAL) {315let label = localize('userSettings', "User");316if (count) {317label += ` (${count})`;318}319320this.userLocalSettings.label = label;321} else if (settingsTarget instanceof URI) {322this.folderSettings.setCount(settingsTarget, count);323}324}325326updateLanguageFilterIndicators(filter: string | undefined) {327this.resetLabels();328if (filter) {329const languageToUse = this.languageService.getLanguageName(filter);330if (languageToUse) {331const languageSuffix = ` [${languageToUse}]`;332this.userLocalSettings.label += languageSuffix;333this.userRemoteSettings.label += languageSuffix;334this.workspaceSettings.label += languageSuffix;335this.folderSettingsAction.label += languageSuffix;336}337}338}339340private onWorkbenchStateChanged(): void {341this.folderSettings.folder = null;342this.update();343if (this.settingsTarget === ConfigurationTarget.WORKSPACE && this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE) {344this.updateTarget(ConfigurationTarget.USER_LOCAL);345}346}347348updateTarget(settingsTarget: SettingsTarget): Promise<void> {349const isSameTarget = this.settingsTarget === settingsTarget ||350settingsTarget instanceof URI &&351this.settingsTarget instanceof URI &&352isEqual(this.settingsTarget, settingsTarget);353354if (!isSameTarget) {355this.settingsTarget = settingsTarget;356this._onDidTargetChange.fire(this.settingsTarget);357}358359return Promise.resolve(undefined);360}361362private async update(): Promise<void> {363this.settingsSwitcherBar.domNode.classList.toggle('empty-workbench', this.contextService.getWorkbenchState() === WorkbenchState.EMPTY);364this.userRemoteSettings.enabled = !!(this.options.enableRemoteSettings && this.environmentService.remoteAuthority);365this.workspaceSettings.enabled = this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY;366this.folderSettings.action.enabled = this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE && this.contextService.getWorkspace().folders.length > 0;367368this.workspaceSettings.tooltip = localize('workspaceSettings', "Workspace");369}370}371372export interface SearchOptions extends IHistoryInputOptions {373focusKey?: IContextKey<boolean>;374showResultCount?: boolean;375ariaLive?: string;376ariaLabelledBy?: string;377}378379export class SearchWidget extends Widget {380381domNode!: HTMLElement;382383private countElement!: HTMLElement;384private searchContainer!: HTMLElement;385inputBox!: HistoryInputBox;386private controlsDiv!: HTMLElement;387388private readonly _onDidChange: Emitter<string> = this._register(new Emitter<string>());389public get onDidChange(): Event<string> { return this._onDidChange.event; }390391private readonly _onFocus: Emitter<void> = this._register(new Emitter<void>());392public get onFocus(): Event<void> { return this._onFocus.event; }393394constructor(parent: HTMLElement, protected options: SearchOptions,395@IContextViewService private readonly contextViewService: IContextViewService,396@IInstantiationService protected instantiationService: IInstantiationService,397@IContextKeyService private readonly contextKeyService: IContextKeyService,398@IKeybindingService protected readonly keybindingService: IKeybindingService399) {400super();401this.create(parent);402}403404private create(parent: HTMLElement) {405this.domNode = DOM.append(parent, DOM.$('div.settings-header-widget'));406this.createSearchContainer(DOM.append(this.domNode, DOM.$('div.settings-search-container')));407this.controlsDiv = DOM.append(this.domNode, DOM.$('div.settings-search-controls'));408409if (this.options.showResultCount) {410this.countElement = DOM.append(this.controlsDiv, DOM.$('.settings-count-widget'));411412this.countElement.style.backgroundColor = asCssVariable(badgeBackground);413this.countElement.style.color = asCssVariable(badgeForeground);414this.countElement.style.border = `1px solid ${asCssVariable(contrastBorder)}`;415}416417this.inputBox.inputElement.setAttribute('aria-live', this.options.ariaLive || 'off');418if (this.options.ariaLabelledBy) {419this.inputBox.inputElement.setAttribute('aria-labelledBy', this.options.ariaLabelledBy);420}421const focusTracker = this._register(DOM.trackFocus(this.inputBox.inputElement));422this._register(focusTracker.onDidFocus(() => this._onFocus.fire()));423424const focusKey = this.options.focusKey;425if (focusKey) {426this._register(focusTracker.onDidFocus(() => focusKey.set(true)));427this._register(focusTracker.onDidBlur(() => focusKey.set(false)));428}429}430431private createSearchContainer(searchContainer: HTMLElement) {432this.searchContainer = searchContainer;433const searchInput = DOM.append(this.searchContainer, DOM.$('div.settings-search-input'));434this.inputBox = this._register(this.createInputBox(searchInput));435this._register(this.inputBox.onDidChange(value => this._onDidChange.fire(value)));436}437438protected createInputBox(parent: HTMLElement): HistoryInputBox {439const showHistoryHint = () => showHistoryKeybindingHint(this.keybindingService);440return new ContextScopedHistoryInputBox(parent, this.contextViewService, { ...this.options, showHistoryHint }, this.contextKeyService);441}442443showMessage(message: string): void {444// Avoid setting the aria-label unnecessarily, the screenreader will read the count every time it's set, since it's aria-live:assertive. #50968445if (this.countElement && message !== this.countElement.textContent) {446this.countElement.textContent = message;447this.inputBox.inputElement.setAttribute('aria-label', message);448this.inputBox.inputElement.style.paddingRight = this.getControlsWidth() + 'px';449}450}451452layout(dimension: DOM.Dimension) {453if (dimension.width < 400) {454this.countElement?.classList.add('hide');455456this.inputBox.inputElement.style.paddingRight = '0px';457} else {458this.countElement?.classList.remove('hide');459460this.inputBox.inputElement.style.paddingRight = this.getControlsWidth() + 'px';461}462}463464private getControlsWidth(): number {465const countWidth = this.countElement ? DOM.getTotalWidth(this.countElement) : 0;466return countWidth + 20;467}468469focus() {470this.inputBox.focus();471if (this.getValue()) {472this.inputBox.select();473}474}475476hasFocus(): boolean {477return this.inputBox.hasFocus();478}479480clear() {481this.inputBox.value = '';482}483484getValue(): string {485return this.inputBox.value;486}487488setValue(value: string): string {489return this.inputBox.value = value;490}491492override dispose(): void {493this.options.focusKey?.set(false);494super.dispose();495}496}497498export class EditPreferenceWidget<T> extends Disposable {499500private _line: number = -1;501private _preferences: T[] = [];502503private readonly _editPreferenceDecoration: IEditorDecorationsCollection;504505private readonly _onClick = this._register(new Emitter<IEditorMouseEvent>());506readonly onClick: Event<IEditorMouseEvent> = this._onClick.event;507508constructor(private editor: ICodeEditor) {509super();510this._editPreferenceDecoration = this.editor.createDecorationsCollection();511this._register(this.editor.onMouseDown((e: IEditorMouseEvent) => {512if (e.target.type !== MouseTargetType.GUTTER_GLYPH_MARGIN || e.target.detail.isAfterLines || !this.isVisible()) {513return;514}515this._onClick.fire(e);516}));517}518519get preferences(): T[] {520return this._preferences;521}522523getLine(): number {524return this._line;525}526527show(line: number, hoverMessage: string, preferences: T[]): void {528this._preferences = preferences;529const newDecoration: IModelDeltaDecoration[] = [];530this._line = line;531newDecoration.push({532options: {533description: 'edit-preference-widget-decoration',534glyphMarginClassName: ThemeIcon.asClassName(settingsEditIcon),535glyphMarginHoverMessage: new MarkdownString().appendText(hoverMessage),536stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,537},538range: {539startLineNumber: line,540startColumn: 1,541endLineNumber: line,542endColumn: 1543}544});545this._editPreferenceDecoration.set(newDecoration);546}547548hide(): void {549this._editPreferenceDecoration.clear();550}551552isVisible(): boolean {553return this._editPreferenceDecoration.length > 0;554}555556override dispose(): void {557this.hide();558super.dispose();559}560}561562563