Path: blob/main/src/vs/workbench/contrib/preferences/browser/settingsWidgets.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 { BrowserFeatures } from '../../../../base/browser/canIUse.js';6import * as DOM from '../../../../base/browser/dom.js';7import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';8import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';9import { Button } from '../../../../base/browser/ui/button/button.js';10import { applyDragImage } from '../../../../base/browser/ui/dnd/dnd.js';11import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js';12import { SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js';13import { Toggle, unthemedToggleStyles } from '../../../../base/browser/ui/toggle/toggle.js';14import { IAction } from '../../../../base/common/actions.js';15import { disposableTimeout } from '../../../../base/common/async.js';16import { Codicon } from '../../../../base/common/codicons.js';17import { Emitter, Event } from '../../../../base/common/event.js';18import { MarkdownString } from '../../../../base/common/htmlContent.js';19import { KeyCode } from '../../../../base/common/keyCodes.js';20import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';21import { isIOS } from '../../../../base/common/platform.js';22import { ThemeIcon } from '../../../../base/common/themables.js';23import { isDefined, isUndefinedOrNull } from '../../../../base/common/types.js';24import { localize } from '../../../../nls.js';25import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';26import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js';27import { IHoverService } from '../../../../platform/hover/browser/hover.js';28import { defaultButtonStyles, getInputBoxStyle, getSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js';29import { IThemeService } from '../../../../platform/theme/common/themeService.js';30import { hasNativeContextMenu } from '../../../../platform/window/common/window.js';31import { SettingValueType } from '../../../services/preferences/common/preferences.js';32import { settingsSelectBackground, settingsSelectBorder, settingsSelectForeground, settingsSelectListBorder, settingsTextInputBackground, settingsTextInputBorder, settingsTextInputForeground } from '../common/settingsEditorColorRegistry.js';33import './media/settingsWidgets.css';34import { settingsDiscardIcon, settingsEditIcon, settingsRemoveIcon } from './preferencesIcons.js';3536const $ = DOM.$;3738type EditKey = 'none' | 'create' | number;3940type RowElementGroup = {41rowElement: HTMLElement;42keyElement: HTMLElement;43valueElement?: HTMLElement;44};4546type IListViewItem<TDataItem extends object> = TDataItem & {47editing?: boolean;48selected?: boolean;49};5051export class ListSettingListModel<TDataItem extends object> {52protected _dataItems: TDataItem[] = [];53private _editKey: EditKey | null = null;54private _selectedIdx: number | null = null;55private _newDataItem: TDataItem;5657get items(): IListViewItem<TDataItem>[] {58const items = this._dataItems.map((item, i) => {59const editing = typeof this._editKey === 'number' && this._editKey === i;60return {61...item,62editing,63selected: i === this._selectedIdx || editing64};65});6667if (this._editKey === 'create') {68items.push({69editing: true,70selected: true,71...this._newDataItem,72});73}7475return items;76}7778constructor(newItem: TDataItem) {79this._newDataItem = newItem;80}8182setEditKey(key: EditKey): void {83this._editKey = key;84}8586setValue(listData: TDataItem[]): void {87this._dataItems = listData;88}8990select(idx: number | null): void {91this._selectedIdx = idx;92}9394getSelected(): number | null {95return this._selectedIdx;96}9798selectNext(): void {99if (typeof this._selectedIdx === 'number') {100this._selectedIdx = Math.min(this._selectedIdx + 1, this._dataItems.length - 1);101} else {102this._selectedIdx = 0;103}104}105106selectPrevious(): void {107if (typeof this._selectedIdx === 'number') {108this._selectedIdx = Math.max(this._selectedIdx - 1, 0);109} else {110this._selectedIdx = 0;111}112}113}114115export interface ISettingListChangeEvent<TDataItem extends object> {116type: 'change';117originalItem: TDataItem;118newItem: TDataItem;119targetIndex: number;120}121122export interface ISettingListAddEvent<TDataItem extends object> {123type: 'add';124newItem: TDataItem;125targetIndex: number;126}127128export interface ISettingListMoveEvent<TDataItem extends object> {129type: 'move';130originalItem: TDataItem;131newItem: TDataItem;132targetIndex: number;133sourceIndex: number;134}135136export interface ISettingListRemoveEvent<TDataItem extends object> {137type: 'remove';138originalItem: TDataItem;139targetIndex: number;140}141142export interface ISettingListResetEvent<TDataItem extends object> {143type: 'reset';144originalItem: TDataItem;145targetIndex: number;146}147148export type SettingListEvent<TDataItem extends object> = ISettingListChangeEvent<TDataItem> | ISettingListAddEvent<TDataItem> | ISettingListMoveEvent<TDataItem> | ISettingListRemoveEvent<TDataItem> | ISettingListResetEvent<TDataItem>;149150export abstract class AbstractListSettingWidget<TDataItem extends object> extends Disposable {151private listElement: HTMLElement;152private rowElements: HTMLElement[] = [];153154protected readonly _onDidChangeList = this._register(new Emitter<SettingListEvent<TDataItem>>());155protected readonly model = new ListSettingListModel<TDataItem>(this.getEmptyItem());156protected readonly listDisposables = this._register(new DisposableStore());157158readonly onDidChangeList: Event<SettingListEvent<TDataItem>> = this._onDidChangeList.event;159160get domNode(): HTMLElement {161return this.listElement;162}163164get items(): TDataItem[] {165return this.model.items;166}167168protected get isReadOnly(): boolean {169return false;170}171172constructor(173private container: HTMLElement,174@IThemeService protected readonly themeService: IThemeService,175@IContextViewService protected readonly contextViewService: IContextViewService,176@IConfigurationService protected readonly configurationService: IConfigurationService,177) {178super();179180this.listElement = DOM.append(container, $('div'));181this.listElement.setAttribute('role', 'list');182this.getContainerClasses().forEach(c => this.listElement.classList.add(c));183DOM.append(container, this.renderAddButton());184this.renderList();185186this._register(DOM.addDisposableListener(this.listElement, DOM.EventType.POINTER_DOWN, e => this.onListClick(e)));187this._register(DOM.addDisposableListener(this.listElement, DOM.EventType.DBLCLICK, e => this.onListDoubleClick(e)));188189this._register(DOM.addStandardDisposableListener(this.listElement, 'keydown', (e: StandardKeyboardEvent) => {190if (e.equals(KeyCode.UpArrow)) {191this.selectPreviousRow();192} else if (e.equals(KeyCode.DownArrow)) {193this.selectNextRow();194} else {195return;196}197198e.preventDefault();199e.stopPropagation();200}));201}202203setValue(listData: TDataItem[]): void {204this.model.setValue(listData);205this.renderList();206}207208abstract isItemNew(item: TDataItem): boolean;209protected abstract getEmptyItem(): TDataItem;210protected abstract getContainerClasses(): string[];211protected abstract getActionsForItem(item: TDataItem, idx: number): IAction[];212protected abstract renderItem(item: TDataItem, idx: number): RowElementGroup;213protected abstract renderEdit(item: TDataItem, idx: number): HTMLElement;214protected abstract addTooltipsToRow(rowElement: RowElementGroup, item: TDataItem): void;215protected abstract getLocalizedStrings(): {216deleteActionTooltip: string;217editActionTooltip: string;218addButtonLabel: string;219};220221protected renderHeader(): HTMLElement | undefined {222return;223}224225protected isAddButtonVisible(): boolean {226return true;227}228229protected renderList(): void {230const focused = DOM.isAncestorOfActiveElement(this.listElement);231232DOM.clearNode(this.listElement);233this.listDisposables.clear();234235const newMode = this.model.items.some(item => !!(item.editing && this.isItemNew(item)));236this.container.classList.toggle('setting-list-hide-add-button', !this.isAddButtonVisible() || newMode);237238if (this.model.items.length) {239this.listElement.tabIndex = 0;240} else {241this.listElement.removeAttribute('tabIndex');242}243244const header = this.renderHeader();245246if (header) {247this.listElement.appendChild(header);248}249250this.rowElements = this.model.items.map((item, i) => this.renderDataOrEditItem(item, i, focused));251this.rowElements.forEach(rowElement => this.listElement.appendChild(rowElement));252253}254255protected createBasicSelectBox(value: IObjectEnumData): SelectBox {256const selectBoxOptions = value.options.map(({ value, description }) => ({ text: value, description }));257const selected = value.options.findIndex(option => value.data === option.value);258259const styles = getSelectBoxStyles({260selectBackground: settingsSelectBackground,261selectForeground: settingsSelectForeground,262selectBorder: settingsSelectBorder,263selectListBorder: settingsSelectListBorder264});265266267const selectBox = new SelectBox(selectBoxOptions, selected, this.contextViewService, styles, {268useCustomDrawn: !hasNativeContextMenu(this.configurationService) || !(isIOS && BrowserFeatures.pointerEvents)269});270return selectBox;271}272273protected editSetting(idx: number): void {274this.model.setEditKey(idx);275this.renderList();276}277278public cancelEdit(): void {279this.model.setEditKey('none');280this.renderList();281}282283protected handleItemChange(originalItem: TDataItem, changedItem: TDataItem, idx: number) {284this.model.setEditKey('none');285286if (this.isItemNew(originalItem)) {287this._onDidChangeList.fire({288type: 'add',289newItem: changedItem,290targetIndex: idx,291});292} else {293this._onDidChangeList.fire({294type: 'change',295originalItem,296newItem: changedItem,297targetIndex: idx,298});299}300301this.renderList();302}303304protected renderDataOrEditItem(item: IListViewItem<TDataItem>, idx: number, listFocused: boolean): HTMLElement {305const rowElement = item.editing ?306this.renderEdit(item, idx) :307this.renderDataItem(item, idx, listFocused);308309rowElement.setAttribute('role', 'listitem');310311return rowElement;312}313314private renderDataItem(item: IListViewItem<TDataItem>, idx: number, listFocused: boolean): HTMLElement {315const rowElementGroup = this.renderItem(item, idx);316const rowElement = rowElementGroup.rowElement;317318rowElement.setAttribute('data-index', idx + '');319rowElement.setAttribute('tabindex', item.selected ? '0' : '-1');320rowElement.classList.toggle('selected', item.selected);321322const actionBar = new ActionBar(rowElement);323this.listDisposables.add(actionBar);324325actionBar.push(this.getActionsForItem(item, idx), { icon: true, label: true });326this.addTooltipsToRow(rowElementGroup, item);327328if (item.selected && listFocused) {329disposableTimeout(() => rowElement.focus(), undefined, this.listDisposables);330}331332this.listDisposables.add(DOM.addDisposableListener(rowElement, 'click', (e) => {333// There is a parent list widget, which is the one that holds the list of settings.334// Prevent the parent widget from trying to interpret this click event.335e.stopPropagation();336}));337338return rowElement;339}340341private renderAddButton(): HTMLElement {342const rowElement = $('.setting-list-new-row');343344const startAddButton = this._register(new Button(rowElement, defaultButtonStyles));345startAddButton.label = this.getLocalizedStrings().addButtonLabel;346startAddButton.element.classList.add('setting-list-addButton');347348this._register(startAddButton.onDidClick(() => {349this.model.setEditKey('create');350this.renderList();351}));352353return rowElement;354}355356private onListClick(e: PointerEvent): void {357const targetIdx = this.getClickedItemIndex(e);358if (targetIdx < 0) {359return;360}361362e.preventDefault();363e.stopImmediatePropagation();364if (this.model.getSelected() === targetIdx) {365return;366}367368this.selectRow(targetIdx);369}370371private onListDoubleClick(e: MouseEvent): void {372const targetIdx = this.getClickedItemIndex(e);373if (targetIdx < 0) {374return;375}376377if (this.isReadOnly) {378return;379}380381const item = this.model.items[targetIdx];382if (item) {383this.editSetting(targetIdx);384e.preventDefault();385e.stopPropagation();386}387}388389private getClickedItemIndex(e: MouseEvent): number {390if (!e.target) {391return -1;392}393394const actionbar = DOM.findParentWithClass(e.target as HTMLElement, 'monaco-action-bar');395if (actionbar) {396// Don't handle doubleclicks inside the action bar397return -1;398}399400const element = DOM.findParentWithClass(e.target as HTMLElement, 'setting-list-row');401if (!element) {402return -1;403}404405const targetIdxStr = element.getAttribute('data-index');406if (!targetIdxStr) {407return -1;408}409410const targetIdx = parseInt(targetIdxStr);411return targetIdx;412}413414private selectRow(idx: number): void {415this.model.select(idx);416this.rowElements.forEach(row => row.classList.remove('selected'));417418const selectedRow = this.rowElements[this.model.getSelected()!];419420selectedRow.classList.add('selected');421selectedRow.focus();422}423424private selectNextRow(): void {425this.model.selectNext();426this.selectRow(this.model.getSelected()!);427}428429private selectPreviousRow(): void {430this.model.selectPrevious();431this.selectRow(this.model.getSelected()!);432}433}434435interface IListSetValueOptions {436showAddButton: boolean;437keySuggester?: IObjectKeySuggester;438}439440export interface IListDataItem {441value: ObjectKey;442sibling?: string;443}444445interface ListSettingWidgetDragDetails<TListDataItem extends IListDataItem> {446element: HTMLElement;447item: TListDataItem;448itemIndex: number;449}450451export class ListSettingWidget<TListDataItem extends IListDataItem> extends AbstractListSettingWidget<TListDataItem> {452private keyValueSuggester: IObjectKeySuggester | undefined;453private showAddButton: boolean = true;454455override setValue(listData: TListDataItem[], options?: IListSetValueOptions) {456this.keyValueSuggester = options?.keySuggester;457this.showAddButton = options?.showAddButton ?? true;458super.setValue(listData);459}460461constructor(462container: HTMLElement,463@IThemeService themeService: IThemeService,464@IContextViewService contextViewService: IContextViewService,465@IHoverService protected readonly hoverService: IHoverService,466@IConfigurationService configurationService: IConfigurationService,467) {468super(container, themeService, contextViewService, configurationService);469}470471protected getEmptyItem(): TListDataItem {472// eslint-disable-next-line local/code-no-dangerous-type-assertions473return {474value: {475type: 'string',476data: ''477}478} as TListDataItem;479}480481protected override isAddButtonVisible(): boolean {482return this.showAddButton;483}484485protected getContainerClasses(): string[] {486return ['setting-list-widget'];487}488489protected getActionsForItem(item: TListDataItem, idx: number): IAction[] {490if (this.isReadOnly) {491return [];492}493return [494{495class: ThemeIcon.asClassName(settingsEditIcon),496enabled: true,497id: 'workbench.action.editListItem',498tooltip: this.getLocalizedStrings().editActionTooltip,499run: () => this.editSetting(idx)500},501{502class: ThemeIcon.asClassName(settingsRemoveIcon),503enabled: true,504id: 'workbench.action.removeListItem',505tooltip: this.getLocalizedStrings().deleteActionTooltip,506run: () => this._onDidChangeList.fire({ type: 'remove', originalItem: item, targetIndex: idx })507}508] as IAction[];509}510511private dragDetails: ListSettingWidgetDragDetails<TListDataItem> | undefined;512513protected renderItem(item: TListDataItem, idx: number): RowElementGroup {514const rowElement = $('.setting-list-row');515const valueElement = DOM.append(rowElement, $('.setting-list-value'));516const siblingElement = DOM.append(rowElement, $('.setting-list-sibling'));517518valueElement.textContent = item.value.data.toString();519if (item.sibling) {520siblingElement.textContent = `when: ${item.sibling}`;521} else {522siblingElement.textContent = null;523valueElement.classList.add('no-sibling');524}525526this.addDragAndDrop(rowElement, item, idx);527return { rowElement, keyElement: valueElement, valueElement: siblingElement };528}529530protected addDragAndDrop(rowElement: HTMLElement, item: TListDataItem, idx: number) {531if (this.model.items.every(item => !item.editing)) {532rowElement.draggable = true;533rowElement.classList.add('draggable');534} else {535rowElement.draggable = false;536rowElement.classList.remove('draggable');537}538539this.listDisposables.add(DOM.addDisposableListener(rowElement, DOM.EventType.DRAG_START, (ev) => {540this.dragDetails = {541element: rowElement,542item,543itemIndex: idx544};545546applyDragImage(ev, rowElement, item.value.data);547}));548this.listDisposables.add(DOM.addDisposableListener(rowElement, DOM.EventType.DRAG_OVER, (ev) => {549if (!this.dragDetails) {550return false;551}552ev.preventDefault();553if (ev.dataTransfer) {554ev.dataTransfer.dropEffect = 'move';555}556return true;557}));558let counter = 0;559this.listDisposables.add(DOM.addDisposableListener(rowElement, DOM.EventType.DRAG_ENTER, (ev) => {560counter++;561rowElement.classList.add('drag-hover');562}));563this.listDisposables.add(DOM.addDisposableListener(rowElement, DOM.EventType.DRAG_LEAVE, (ev) => {564counter--;565if (!counter) {566rowElement.classList.remove('drag-hover');567}568}));569this.listDisposables.add(DOM.addDisposableListener(rowElement, DOM.EventType.DROP, (ev) => {570// cancel the op if we dragged to a completely different setting571if (!this.dragDetails) {572return false;573}574ev.preventDefault();575counter = 0;576if (this.dragDetails.element !== rowElement) {577this._onDidChangeList.fire({578type: 'move',579originalItem: this.dragDetails.item,580sourceIndex: this.dragDetails.itemIndex,581newItem: item,582targetIndex: idx583});584}585return true;586}));587this.listDisposables.add(DOM.addDisposableListener(rowElement, DOM.EventType.DRAG_END, (ev) => {588counter = 0;589rowElement.classList.remove('drag-hover');590ev.dataTransfer?.clearData();591if (this.dragDetails) {592this.dragDetails = undefined;593}594}));595}596597protected renderEdit(item: TListDataItem, idx: number): HTMLElement {598const rowElement = $('.setting-list-edit-row');599let valueInput: InputBox | SelectBox;600let currentDisplayValue: string;601let currentEnumOptions: IObjectEnumOption[] | undefined;602603if (this.keyValueSuggester) {604const enumData = this.keyValueSuggester(this.model.items.map(({ value: { data } }) => data), idx);605item = {606...item,607value: {608type: 'enum',609data: item.value.data,610options: enumData ? enumData.options : []611}612};613}614615switch (item.value.type) {616case 'string':617valueInput = this.renderInputBox(item.value, rowElement);618break;619case 'enum':620valueInput = this.renderDropdown(item.value, rowElement);621currentEnumOptions = item.value.options;622if (item.value.options.length) {623currentDisplayValue = this.isItemNew(item) ?624currentEnumOptions[0].value : item.value.data;625}626break;627}628629const updatedInputBoxItem = (): TListDataItem => {630const inputBox = valueInput as InputBox;631// eslint-disable-next-line local/code-no-dangerous-type-assertions632return {633value: {634type: 'string',635data: inputBox.value636},637sibling: siblingInput?.value638} as TListDataItem;639};640const updatedSelectBoxItem = (selectedValue: string): TListDataItem => {641// eslint-disable-next-line local/code-no-dangerous-type-assertions642return {643value: {644type: 'enum',645data: selectedValue,646options: currentEnumOptions ?? []647}648} as TListDataItem;649};650const onKeyDown = (e: StandardKeyboardEvent) => {651if (e.equals(KeyCode.Enter)) {652this.handleItemChange(item, updatedInputBoxItem(), idx);653} else if (e.equals(KeyCode.Escape)) {654this.cancelEdit();655e.preventDefault();656}657rowElement?.focus();658};659660if (item.value.type !== 'string') {661const selectBox = valueInput as SelectBox;662this.listDisposables.add(663selectBox.onDidSelect(({ selected }) => {664currentDisplayValue = selected;665})666);667} else {668const inputBox = valueInput as InputBox;669this.listDisposables.add(670DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_DOWN, onKeyDown)671);672}673674let siblingInput: InputBox | undefined;675if (!isUndefinedOrNull(item.sibling)) {676siblingInput = new InputBox(rowElement, this.contextViewService, {677placeholder: this.getLocalizedStrings().siblingInputPlaceholder,678inputBoxStyles: getInputBoxStyle({679inputBackground: settingsTextInputBackground,680inputForeground: settingsTextInputForeground,681inputBorder: settingsTextInputBorder682})683});684siblingInput.element.classList.add('setting-list-siblingInput');685this.listDisposables.add(siblingInput);686siblingInput.value = item.sibling;687688this.listDisposables.add(689DOM.addStandardDisposableListener(siblingInput.inputElement, DOM.EventType.KEY_DOWN, onKeyDown)690);691} else if (valueInput instanceof InputBox) {692valueInput.element.classList.add('no-sibling');693}694695const okButton = this.listDisposables.add(new Button(rowElement, defaultButtonStyles));696okButton.label = localize('okButton', "OK");697okButton.element.classList.add('setting-list-ok-button');698699this.listDisposables.add(okButton.onDidClick(() => {700if (item.value.type === 'string') {701this.handleItemChange(item, updatedInputBoxItem(), idx);702} else {703this.handleItemChange(item, updatedSelectBoxItem(currentDisplayValue), idx);704}705}));706707const cancelButton = this.listDisposables.add(new Button(rowElement, { secondary: true, ...defaultButtonStyles }));708cancelButton.label = localize('cancelButton', "Cancel");709cancelButton.element.classList.add('setting-list-cancel-button');710711this.listDisposables.add(cancelButton.onDidClick(() => this.cancelEdit()));712713this.listDisposables.add(714disposableTimeout(() => {715valueInput.focus();716if (valueInput instanceof InputBox) {717valueInput.select();718}719})720);721722return rowElement;723}724725override isItemNew(item: TListDataItem): boolean {726return item.value.data === '';727}728729protected addTooltipsToRow(rowElementGroup: RowElementGroup, { value, sibling }: TListDataItem) {730const title = isUndefinedOrNull(sibling)731? localize('listValueHintLabel', "List item `{0}`", value.data)732: localize('listSiblingHintLabel', "List item `{0}` with sibling `${1}`", value.data, sibling);733734const { rowElement } = rowElementGroup;735this.listDisposables.add(this.hoverService.setupDelayedHover(rowElement, { content: title }));736rowElement.setAttribute('aria-label', title);737}738739protected getLocalizedStrings() {740return {741deleteActionTooltip: localize('removeItem', "Remove Item"),742editActionTooltip: localize('editItem', "Edit Item"),743addButtonLabel: localize('addItem', "Add Item"),744inputPlaceholder: localize('itemInputPlaceholder', "Item..."),745siblingInputPlaceholder: localize('listSiblingInputPlaceholder', "Sibling..."),746};747}748749private renderInputBox(value: ObjectValue, rowElement: HTMLElement): InputBox {750const valueInput = new InputBox(rowElement, this.contextViewService, {751placeholder: this.getLocalizedStrings().inputPlaceholder,752inputBoxStyles: getInputBoxStyle({753inputBackground: settingsTextInputBackground,754inputForeground: settingsTextInputForeground,755inputBorder: settingsTextInputBorder756})757});758759valueInput.element.classList.add('setting-list-valueInput');760this.listDisposables.add(valueInput);761valueInput.value = value.data.toString();762763return valueInput;764}765766private renderDropdown(value: ObjectKey, rowElement: HTMLElement): SelectBox {767if (value.type !== 'enum') {768throw new Error('Valuetype must be enum.');769}770const selectBox = this.createBasicSelectBox(value);771772const wrapper = $('.setting-list-object-list-row');773selectBox.render(wrapper);774rowElement.appendChild(wrapper);775776return selectBox;777}778}779780export class ExcludeSettingWidget extends ListSettingWidget<IIncludeExcludeDataItem> {781protected override getContainerClasses() {782return ['setting-list-include-exclude-widget'];783}784785protected override addDragAndDrop(rowElement: HTMLElement, item: IIncludeExcludeDataItem, idx: number) {786return;787}788789protected override addTooltipsToRow(rowElementGroup: RowElementGroup, item: IIncludeExcludeDataItem): void {790let title = isUndefinedOrNull(item.sibling)791? localize('excludePatternHintLabel', "Exclude files matching `{0}`", item.value.data)792: localize('excludeSiblingHintLabel', "Exclude files matching `{0}`, only when a file matching `{1}` is present", item.value.data, item.sibling);793794if (item.source) {795title += localize('excludeIncludeSource', ". Default value provided by `{0}`", item.source);796}797798const markdownTitle = new MarkdownString().appendMarkdown(title);799800const { rowElement } = rowElementGroup;801this.listDisposables.add(this.hoverService.setupDelayedHover(rowElement, { content: markdownTitle }));802rowElement.setAttribute('aria-label', title);803}804805protected override getLocalizedStrings() {806return {807deleteActionTooltip: localize('removeExcludeItem', "Remove Exclude Item"),808editActionTooltip: localize('editExcludeItem', "Edit Exclude Item"),809addButtonLabel: localize('addPattern', "Add Pattern"),810inputPlaceholder: localize('excludePatternInputPlaceholder', "Exclude Pattern..."),811siblingInputPlaceholder: localize('excludeSiblingInputPlaceholder', "When Pattern Is Present..."),812};813}814}815816export class IncludeSettingWidget extends ListSettingWidget<IIncludeExcludeDataItem> {817protected override getContainerClasses() {818return ['setting-list-include-exclude-widget'];819}820821protected override addDragAndDrop(rowElement: HTMLElement, item: IIncludeExcludeDataItem, idx: number) {822return;823}824825protected override addTooltipsToRow(rowElementGroup: RowElementGroup, item: IIncludeExcludeDataItem): void {826let title = isUndefinedOrNull(item.sibling)827? localize('includePatternHintLabel', "Include files matching `{0}`", item.value.data)828: localize('includeSiblingHintLabel', "Include files matching `{0}`, only when a file matching `{1}` is present", item.value.data, item.sibling);829830if (item.source) {831title += localize('excludeIncludeSource', ". Default value provided by `{0}`", item.source);832}833834const markdownTitle = new MarkdownString().appendMarkdown(title);835836const { rowElement } = rowElementGroup;837this.listDisposables.add(this.hoverService.setupDelayedHover(rowElement, { content: markdownTitle }));838rowElement.setAttribute('aria-label', title);839}840841protected override getLocalizedStrings() {842return {843deleteActionTooltip: localize('removeIncludeItem', "Remove Include Item"),844editActionTooltip: localize('editIncludeItem', "Edit Include Item"),845addButtonLabel: localize('addPattern', "Add Pattern"),846inputPlaceholder: localize('includePatternInputPlaceholder', "Include Pattern..."),847siblingInputPlaceholder: localize('includeSiblingInputPlaceholder', "When Pattern Is Present..."),848};849}850}851852interface IObjectStringData {853type: 'string';854data: string;855}856857export interface IObjectEnumOption {858value: string;859description?: string;860}861862interface IObjectEnumData {863type: 'enum';864data: string;865options: IObjectEnumOption[];866}867868interface IObjectBoolData {869type: 'boolean';870data: boolean;871}872873type ObjectKey = IObjectStringData | IObjectEnumData;874export type ObjectValue = IObjectStringData | IObjectEnumData | IObjectBoolData;875type ObjectWidget = InputBox | SelectBox;876877export interface IObjectDataItem {878key: ObjectKey;879value: ObjectValue;880keyDescription?: string;881source?: string;882removable: boolean;883resetable: boolean;884}885886export interface IIncludeExcludeDataItem {887value: ObjectKey;888elementType: SettingValueType;889sibling?: string;890source?: string;891}892893export interface IObjectValueSuggester {894(key: string): ObjectValue | undefined;895}896897export interface IObjectKeySuggester {898(existingKeys: string[], idx?: number): IObjectEnumData | undefined;899}900901interface IObjectSetValueOptions {902settingKey: string;903showAddButton: boolean;904isReadOnly?: boolean;905keySuggester?: IObjectKeySuggester;906valueSuggester?: IObjectValueSuggester;907}908909interface IObjectRenderEditWidgetOptions {910isKey: boolean;911idx: number;912readonly originalItem: IObjectDataItem;913readonly changedItem: IObjectDataItem;914update(keyOrValue: ObjectKey | ObjectValue): void;915}916917export class ObjectSettingDropdownWidget extends AbstractListSettingWidget<IObjectDataItem> {918private editable: boolean = true;919private currentSettingKey: string = '';920private showAddButton: boolean = true;921private keySuggester: IObjectKeySuggester = () => undefined;922private valueSuggester: IObjectValueSuggester = () => undefined;923924constructor(925container: HTMLElement,926@IThemeService themeService: IThemeService,927@IContextViewService contextViewService: IContextViewService,928@IHoverService private readonly hoverService: IHoverService,929@IConfigurationService configurationService: IConfigurationService,930) {931super(container, themeService, contextViewService, configurationService);932}933934override setValue(listData: IObjectDataItem[], options?: IObjectSetValueOptions): void {935this.editable = !options?.isReadOnly;936this.showAddButton = options?.showAddButton ?? this.showAddButton;937this.keySuggester = options?.keySuggester ?? this.keySuggester;938this.valueSuggester = options?.valueSuggester ?? this.valueSuggester;939940if (isDefined(options) && options.settingKey !== this.currentSettingKey) {941this.model.setEditKey('none');942this.model.select(null);943this.currentSettingKey = options.settingKey;944}945946super.setValue(listData);947}948949override isItemNew(item: IObjectDataItem): boolean {950return item.key.data === '' && item.value.data === '';951}952953protected override isAddButtonVisible(): boolean {954return this.showAddButton;955}956957protected override get isReadOnly(): boolean {958return !this.editable;959}960961protected getEmptyItem(): IObjectDataItem {962return {963key: { type: 'string', data: '' },964value: { type: 'string', data: '' },965removable: true,966resetable: false967};968}969970protected getContainerClasses() {971return ['setting-list-object-widget'];972}973974protected getActionsForItem(item: IObjectDataItem, idx: number): IAction[] {975if (this.isReadOnly) {976return [];977}978979const actions: IAction[] = [980{981class: ThemeIcon.asClassName(settingsEditIcon),982enabled: true,983id: 'workbench.action.editListItem',984label: '',985tooltip: this.getLocalizedStrings().editActionTooltip,986run: () => this.editSetting(idx)987},988];989990if (item.resetable) {991actions.push({992class: ThemeIcon.asClassName(settingsDiscardIcon),993enabled: true,994id: 'workbench.action.resetListItem',995label: '',996tooltip: this.getLocalizedStrings().resetActionTooltip,997run: () => this._onDidChangeList.fire({ type: 'reset', originalItem: item, targetIndex: idx })998});999}10001001if (item.removable) {1002actions.push({1003class: ThemeIcon.asClassName(settingsRemoveIcon),1004enabled: true,1005id: 'workbench.action.removeListItem',1006label: '',1007tooltip: this.getLocalizedStrings().deleteActionTooltip,1008run: () => this._onDidChangeList.fire({ type: 'remove', originalItem: item, targetIndex: idx })1009});1010}10111012return actions;1013}10141015protected override renderHeader() {1016const header = $('.setting-list-row-header');1017const keyHeader = DOM.append(header, $('.setting-list-object-key'));1018const valueHeader = DOM.append(header, $('.setting-list-object-value'));1019const { keyHeaderText, valueHeaderText } = this.getLocalizedStrings();10201021keyHeader.textContent = keyHeaderText;1022valueHeader.textContent = valueHeaderText;10231024return header;1025}10261027protected renderItem(item: IObjectDataItem, idx: number): RowElementGroup {1028const rowElement = $('.setting-list-row');1029rowElement.classList.add('setting-list-object-row');10301031const keyElement = DOM.append(rowElement, $('.setting-list-object-key'));1032const valueElement = DOM.append(rowElement, $('.setting-list-object-value'));10331034keyElement.textContent = item.key.data;1035valueElement.textContent = item.value.data.toString();10361037return { rowElement, keyElement, valueElement };1038}10391040protected renderEdit(item: IObjectDataItem, idx: number): HTMLElement {1041const rowElement = $('.setting-list-edit-row.setting-list-object-row');10421043const changedItem = { ...item };1044const onKeyChange = (key: ObjectKey) => {1045changedItem.key = key;1046okButton.enabled = key.data !== '';10471048const suggestedValue = this.valueSuggester(key.data) ?? item.value;10491050if (this.shouldUseSuggestion(item.value, changedItem.value, suggestedValue)) {1051onValueChange(suggestedValue);1052renderLatestValue();1053}1054};1055const onValueChange = (value: ObjectValue) => {1056changedItem.value = value;1057};10581059let keyWidget: ObjectWidget | undefined;1060let keyElement: HTMLElement;10611062if (this.showAddButton) {1063if (this.isItemNew(item)) {1064const suggestedKey = this.keySuggester(this.model.items.map(({ key: { data } }) => data));10651066if (isDefined(suggestedKey)) {1067changedItem.key = suggestedKey;1068const suggestedValue = this.valueSuggester(changedItem.key.data);1069onValueChange(suggestedValue ?? changedItem.value);1070}1071}10721073const { widget, element } = this.renderEditWidget(changedItem.key, {1074idx,1075isKey: true,1076originalItem: item,1077changedItem,1078update: onKeyChange,1079});1080keyWidget = widget;1081keyElement = element;1082} else {1083keyElement = $('.setting-list-object-key');1084keyElement.textContent = item.key.data;1085}10861087let valueWidget: ObjectWidget;1088const valueContainer = $('.setting-list-object-value-container');10891090const renderLatestValue = () => {1091const { widget, element } = this.renderEditWidget(changedItem.value, {1092idx,1093isKey: false,1094originalItem: item,1095changedItem,1096update: onValueChange,1097});10981099valueWidget = widget;11001101DOM.clearNode(valueContainer);1102valueContainer.append(element);1103};11041105renderLatestValue();11061107rowElement.append(keyElement, valueContainer);11081109const okButton = this.listDisposables.add(new Button(rowElement, defaultButtonStyles));1110okButton.enabled = changedItem.key.data !== '';1111okButton.label = localize('okButton', "OK");1112okButton.element.classList.add('setting-list-ok-button');11131114this.listDisposables.add(okButton.onDidClick(() => this.handleItemChange(item, changedItem, idx)));11151116const cancelButton = this.listDisposables.add(new Button(rowElement, { secondary: true, ...defaultButtonStyles }));1117cancelButton.label = localize('cancelButton', "Cancel");1118cancelButton.element.classList.add('setting-list-cancel-button');11191120this.listDisposables.add(cancelButton.onDidClick(() => this.cancelEdit()));11211122this.listDisposables.add(1123disposableTimeout(() => {1124const widget = keyWidget ?? valueWidget;11251126widget.focus();11271128if (widget instanceof InputBox) {1129widget.select();1130}1131})1132);11331134return rowElement;1135}11361137private renderEditWidget(1138keyOrValue: ObjectKey | ObjectValue,1139options: IObjectRenderEditWidgetOptions,1140) {1141switch (keyOrValue.type) {1142case 'string':1143return this.renderStringEditWidget(keyOrValue, options);1144case 'enum':1145return this.renderEnumEditWidget(keyOrValue, options);1146case 'boolean':1147return this.renderEnumEditWidget(1148{1149type: 'enum',1150data: keyOrValue.data.toString(),1151options: [{ value: 'true' }, { value: 'false' }],1152},1153options,1154);1155}1156}11571158private renderStringEditWidget(1159keyOrValue: IObjectStringData,1160{ idx, isKey, originalItem, changedItem, update }: IObjectRenderEditWidgetOptions,1161) {1162const wrapper = $(isKey ? '.setting-list-object-input-key' : '.setting-list-object-input-value');1163const inputBox = new InputBox(wrapper, this.contextViewService, {1164placeholder: isKey1165? localize('objectKeyInputPlaceholder', "Key")1166: localize('objectValueInputPlaceholder', "Value"),1167inputBoxStyles: getInputBoxStyle({1168inputBackground: settingsTextInputBackground,1169inputForeground: settingsTextInputForeground,1170inputBorder: settingsTextInputBorder1171})1172});11731174inputBox.element.classList.add('setting-list-object-input');11751176this.listDisposables.add(inputBox);1177inputBox.value = keyOrValue.data;11781179this.listDisposables.add(inputBox.onDidChange(value => update({ ...keyOrValue, data: value })));11801181const onKeyDown = (e: StandardKeyboardEvent) => {1182if (e.equals(KeyCode.Enter)) {1183this.handleItemChange(originalItem, changedItem, idx);1184} else if (e.equals(KeyCode.Escape)) {1185this.cancelEdit();1186e.preventDefault();1187}1188};11891190this.listDisposables.add(1191DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_DOWN, onKeyDown)1192);11931194return { widget: inputBox, element: wrapper };1195}11961197private renderEnumEditWidget(1198keyOrValue: IObjectEnumData,1199{ isKey, changedItem, update }: IObjectRenderEditWidgetOptions,1200) {1201const selectBox = this.createBasicSelectBox(keyOrValue);12021203const changedKeyOrValue = isKey ? changedItem.key : changedItem.value;1204this.listDisposables.add(1205selectBox.onDidSelect(({ selected }) =>1206update(1207changedKeyOrValue.type === 'boolean'1208? { ...changedKeyOrValue, data: selected === 'true' ? true : false }1209: { ...changedKeyOrValue, data: selected },1210)1211)1212);12131214const wrapper = $('.setting-list-object-input');1215wrapper.classList.add(1216isKey ? 'setting-list-object-input-key' : 'setting-list-object-input-value',1217);12181219selectBox.render(wrapper);12201221// Switch to the first item if the user set something invalid in the json1222const selected = keyOrValue.options.findIndex(option => keyOrValue.data === option.value);1223if (selected === -1 && keyOrValue.options.length) {1224update(1225changedKeyOrValue.type === 'boolean'1226? { ...changedKeyOrValue, data: true }1227: { ...changedKeyOrValue, data: keyOrValue.options[0].value }1228);1229} else if (changedKeyOrValue.type === 'boolean') {1230// https://github.com/microsoft/vscode/issues/1295811231update({ ...changedKeyOrValue, data: keyOrValue.data === 'true' });1232}12331234return { widget: selectBox, element: wrapper };1235}12361237private shouldUseSuggestion(originalValue: ObjectValue, previousValue: ObjectValue, newValue: ObjectValue): boolean {1238// suggestion is exactly the same1239if (newValue.type !== 'enum' && newValue.type === previousValue.type && newValue.data === previousValue.data) {1240return false;1241}12421243// item is new, use suggestion1244if (originalValue.data === '') {1245return true;1246}12471248if (previousValue.type === newValue.type && newValue.type !== 'enum') {1249return false;1250}12511252// check if all enum options are the same1253if (previousValue.type === 'enum' && newValue.type === 'enum') {1254const previousEnums = new Set(previousValue.options.map(({ value }) => value));1255newValue.options.forEach(({ value }) => previousEnums.delete(value));12561257// all options are the same1258if (previousEnums.size === 0) {1259return false;1260}1261}12621263return true;1264}12651266protected addTooltipsToRow(rowElementGroup: RowElementGroup, item: IObjectDataItem): void {1267const { keyElement, valueElement, rowElement } = rowElementGroup;12681269let accessibleDescription;1270if (item.source) {1271accessibleDescription = localize('objectPairHintLabelWithSource', "The property `{0}` is set to `{1}` by `{2}`.", item.key.data, item.value.data, item.source);1272} else {1273accessibleDescription = localize('objectPairHintLabel', "The property `{0}` is set to `{1}`.", item.key.data, item.value.data);1274}12751276const markdownString = new MarkdownString().appendMarkdown(accessibleDescription);12771278const keyDescription: string | MarkdownString = this.getEnumDescription(item.key) ?? item.keyDescription ?? markdownString;1279this.listDisposables.add(this.hoverService.setupDelayedHover(keyElement, { content: keyDescription }));12801281const valueDescription: string | MarkdownString = this.getEnumDescription(item.value) ?? markdownString;1282this.listDisposables.add(this.hoverService.setupDelayedHover(valueElement!, { content: valueDescription }));12831284rowElement.setAttribute('aria-label', accessibleDescription);1285}12861287private getEnumDescription(keyOrValue: ObjectKey | ObjectValue): string | undefined {1288const enumDescription = keyOrValue.type === 'enum'1289? keyOrValue.options.find(({ value }) => keyOrValue.data === value)?.description1290: undefined;1291return enumDescription;1292}12931294protected getLocalizedStrings() {1295return {1296deleteActionTooltip: localize('removeItem', "Remove Item"),1297resetActionTooltip: localize('resetItem', "Reset Item"),1298editActionTooltip: localize('editItem', "Edit Item"),1299addButtonLabel: localize('addItem', "Add Item"),1300keyHeaderText: localize('objectKeyHeader', "Item"),1301valueHeaderText: localize('objectValueHeader', "Value"),1302};1303}1304}13051306interface IBoolObjectSetValueOptions {1307settingKey: string;1308}13091310export interface IBoolObjectDataItem {1311key: IObjectStringData;1312value: IObjectBoolData;1313keyDescription?: string;1314source?: string;1315removable: false;1316resetable: boolean;1317}13181319export class ObjectSettingCheckboxWidget extends AbstractListSettingWidget<IBoolObjectDataItem> {1320private currentSettingKey: string = '';13211322constructor(1323container: HTMLElement,1324@IThemeService themeService: IThemeService,1325@IContextViewService contextViewService: IContextViewService,1326@IHoverService private readonly hoverService: IHoverService,1327@IConfigurationService configurationService: IConfigurationService,1328) {1329super(container, themeService, contextViewService, configurationService);1330}13311332override setValue(listData: IBoolObjectDataItem[], options?: IBoolObjectSetValueOptions): void {1333if (isDefined(options) && options.settingKey !== this.currentSettingKey) {1334this.model.setEditKey('none');1335this.model.select(null);1336this.currentSettingKey = options.settingKey;1337}13381339super.setValue(listData);1340}13411342override isItemNew(item: IBoolObjectDataItem): boolean {1343return !item.key.data && !item.value.data;1344}13451346protected getEmptyItem(): IBoolObjectDataItem {1347return {1348key: { type: 'string', data: '' },1349value: { type: 'boolean', data: false },1350removable: false,1351resetable: true1352};1353}13541355protected getContainerClasses() {1356return ['setting-list-object-widget'];1357}13581359protected getActionsForItem(item: IBoolObjectDataItem, idx: number): IAction[] {1360return [];1361}13621363protected override isAddButtonVisible(): boolean {1364return false;1365}13661367protected override renderHeader() {1368return undefined;1369}13701371protected override renderDataOrEditItem(item: IListViewItem<IBoolObjectDataItem>, idx: number, listFocused: boolean): HTMLElement {1372const rowElement = this.renderEdit(item, idx);1373rowElement.setAttribute('role', 'listitem');1374return rowElement;1375}13761377protected renderItem(item: IBoolObjectDataItem, idx: number): RowElementGroup {1378// Return just the containers, since we always render in edit mode anyway1379const rowElement = $('.blank-row');1380const keyElement = $('.blank-row-key');1381return { rowElement, keyElement };1382}13831384protected renderEdit(item: IBoolObjectDataItem, idx: number): HTMLElement {1385const rowElement = $('.setting-list-edit-row.setting-list-object-row.setting-item-bool');13861387const changedItem = { ...item };1388const onValueChange = (newValue: boolean) => {1389changedItem.value.data = newValue;1390this.handleItemChange(item, changedItem, idx);1391};1392const checkboxDescription = item.keyDescription ? `${item.keyDescription} (${item.key.data})` : item.key.data;1393const { element, widget: checkbox } = this.renderEditWidget((changedItem.value as IObjectBoolData).data, checkboxDescription, onValueChange);1394rowElement.appendChild(element);13951396const valueElement = DOM.append(rowElement, $('.setting-list-object-value'));1397valueElement.textContent = checkboxDescription;13981399// We add the tooltips here, because the method is not called by default1400// for widgets in edit mode1401const rowElementGroup = { rowElement, keyElement: valueElement, valueElement: checkbox.domNode };1402this.addTooltipsToRow(rowElementGroup, item);14031404this._register(DOM.addDisposableListener(valueElement, DOM.EventType.MOUSE_DOWN, e => {1405const targetElement = <HTMLElement>e.target;1406if (targetElement.tagName.toLowerCase() !== 'a') {1407checkbox.checked = !checkbox.checked;1408onValueChange(checkbox.checked);1409}1410DOM.EventHelper.stop(e);1411}));14121413return rowElement;1414}14151416private renderEditWidget(1417value: boolean,1418checkboxDescription: string,1419onValueChange: (newValue: boolean) => void1420) {1421const checkbox = new Toggle({1422icon: Codicon.check,1423actionClassName: 'setting-value-checkbox',1424isChecked: value,1425title: checkboxDescription,1426...unthemedToggleStyles1427});14281429this.listDisposables.add(checkbox);14301431const wrapper = $('.setting-list-object-input');1432wrapper.classList.add('setting-list-object-input-key-checkbox');1433checkbox.domNode.classList.add('setting-value-checkbox');1434wrapper.appendChild(checkbox.domNode);14351436this._register(DOM.addDisposableListener(wrapper, DOM.EventType.MOUSE_DOWN, e => {1437checkbox.checked = !checkbox.checked;1438onValueChange(checkbox.checked);14391440// Without this line, the settings editor assumes1441// we lost focus on this setting completely.1442e.stopImmediatePropagation();1443}));14441445return { widget: checkbox, element: wrapper };1446}14471448protected addTooltipsToRow(rowElementGroup: RowElementGroup, item: IBoolObjectDataItem): void {1449const accessibleDescription = localize('objectPairHintLabel', "The property `{0}` is set to `{1}`.", item.key.data, item.value.data);1450const title = item.keyDescription ?? accessibleDescription;1451const { rowElement, keyElement, valueElement } = rowElementGroup;14521453this.listDisposables.add(this.hoverService.setupDelayedHover(keyElement, { content: title }));1454valueElement!.setAttribute('aria-label', accessibleDescription);1455rowElement.setAttribute('aria-label', accessibleDescription);1456}14571458protected getLocalizedStrings() {1459return {1460deleteActionTooltip: localize('removeItem', "Remove Item"),1461resetActionTooltip: localize('resetItem', "Reset Item"),1462editActionTooltip: localize('editItem', "Edit Item"),1463addButtonLabel: localize('addItem', "Add Item"),1464keyHeaderText: localize('objectKeyHeader', "Item"),1465valueHeaderText: localize('objectValueHeader', "Value"),1466};1467}1468}146914701471