diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css index d92067bfd54..ccbcc852cbe 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsWidgets.css @@ -148,9 +148,12 @@ .settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-input { max-width: unset; } +.settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-value-container .setting-list-object-input { + margin-right: 0; +} .settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-ok-button { - margin-right: 4px; + margin: 0 4px; } .settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-widget, @@ -160,6 +163,7 @@ padding: 1px; } +.settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-value-container, .settings-editor > .settings-body > .settings-tree-container .setting-item.setting-item-list .setting-list-object-widget .setting-list-object-input select { width: 100%; height: 24px; diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index 6a9ef119210..eba683f1cc5 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -44,7 +44,7 @@ import { ICssStyleCollector, IColorTheme, IThemeService, registerThemingParticip import { getIgnoredSettings } from 'vs/platform/userDataSync/common/settingsMerge'; import { ITOCEntry } from 'vs/workbench/contrib/preferences/browser/settingsLayout'; import { ISettingsEditorViewState, settingKeyToDisplayFormat, SettingsTreeElement, SettingsTreeGroupChild, SettingsTreeGroupElement, SettingsTreeNewExtensionsElement, SettingsTreeSettingElement } from 'vs/workbench/contrib/preferences/browser/settingsTreeModels'; -import { ExcludeSettingWidget, ISettingListChangeEvent, IListDataItem, ListSettingWidget, settingsHeaderForeground, settingsNumberInputBackground, settingsNumberInputBorder, settingsNumberInputForeground, settingsSelectBackground, settingsSelectBorder, settingsSelectForeground, settingsSelectListBorder, settingsTextInputBackground, settingsTextInputBorder, settingsTextInputForeground, ObjectSettingWidget, IObjectDataItem, IObjectEnumOption, ObjectValue } from 'vs/workbench/contrib/preferences/browser/settingsWidgets'; +import { ExcludeSettingWidget, ISettingListChangeEvent, IListDataItem, ListSettingWidget, settingsHeaderForeground, settingsNumberInputBackground, settingsNumberInputBorder, settingsNumberInputForeground, settingsSelectBackground, settingsSelectBorder, settingsSelectForeground, settingsSelectListBorder, settingsTextInputBackground, settingsTextInputBorder, settingsTextInputForeground, ObjectSettingWidget, IObjectDataItem, IObjectEnumOption, ObjectValue, IObjectValueSuggester } from 'vs/workbench/contrib/preferences/browser/settingsWidgets'; import { SETTINGS_EDITOR_COMMAND_SHOW_CONTEXT_MENU } from 'vs/workbench/contrib/preferences/common/preferences'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; import { ISetting, ISettingsGroup, SettingValueType } from 'vs/workbench/services/preferences/common/preferences'; @@ -174,6 +174,45 @@ function getObjectDisplayValue(element: SettingsTreeSettingElement): IObjectData }); } +function createObjectValueSuggester(element: SettingsTreeSettingElement): IObjectValueSuggester { + const { objectProperties, objectPatternProperties, objectAdditionalProperties } = element.setting; + + const patternsAndSchemas = Object + .entries(objectPatternProperties ?? {}) + .map(([pattern, schema]) => ({ + pattern: new RegExp(pattern), + schema + })); + + return (key: string) => { + let suggestedSchema: IJSONSchema | undefined; + + if (isDefined(objectProperties) && key in objectProperties) { + suggestedSchema = objectProperties[key]; + } + + const patternSchema = suggestedSchema ?? patternsAndSchemas.find(({ pattern }) => pattern.test(key))?.schema; + + if (isDefined(patternSchema)) { + suggestedSchema = patternSchema; + } else if (isDefined(objectAdditionalProperties) && typeof objectAdditionalProperties === 'object') { + suggestedSchema = objectAdditionalProperties; + } + + if (isDefined(suggestedSchema)) { + const type = getObjectValueType(suggestedSchema); + + if (type === 'boolean') { + return { type, data: suggestedSchema.default ?? true }; + } else { + return { type, data: suggestedSchema.default ?? '', options: getEnumOptionsFromSchema(suggestedSchema) }; + } + } + + return; + }; +} + function getListDisplayValue(element: SettingsTreeSettingElement): IListDataItem[] { if (!element.value || !isArray(element.value)) { return []; @@ -1039,13 +1078,13 @@ export class SettingObjectRenderer extends AbstractSettingRenderer implements IT protected renderValue(dataElement: SettingsTreeSettingElement, template: ISettingObjectItemTemplate, onChange: (value: string) => void): void { const items = getObjectDisplayValue(dataElement); - template.objectWidget.setValue(items, { showAddButton: ( isDefined(dataElement.setting.objectAdditionalProperties) || isDefined(dataElement.setting.objectPatternProperties) || !areAllPropertiesDefined(Object.keys(dataElement.setting.objectProperties ?? {}), items) ), + valueSuggester: createObjectValueSuggester(dataElement), }); this.setElementAriaLabels(dataElement, this.templateId, template); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts b/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts index 1ef00622788..142084149e8 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsWidgets.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from 'vs/base/browser/dom'; -import { StandardKeyboardEvent, IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Button } from 'vs/base/browser/ui/button/button'; import { InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; @@ -25,6 +25,7 @@ import { preferencesEditIcon } from 'vs/workbench/contrib/preferences/browser/pr import { SelectBox } from 'vs/base/browser/ui/selectBox/selectBox'; import { isIOS } from 'vs/base/common/platform'; import { BrowserFeatures } from 'vs/base/browser/canIUse'; +import { debounce } from 'vs/base/common/decorators'; const $ = DOM.$; export const settingsHeaderForeground = registerColor('settings.headerForeground', { light: '#444444', dark: '#e7e7e7', hc: '#ffffff' }, localize('headerForeground', "The foreground color for a section header or active title.")); @@ -218,12 +219,6 @@ export interface ISettingListChangeEvent { targetIndex?: number; } -interface IEditHandlers { - onKeydown(event: IKeyboardEvent, updatedItem: TDataItem): void - onSubmit(updatedItem: TDataItem): void - onCancel(): void -} - abstract class AbstractListSettingWidget extends Disposable { private listElement: HTMLElement; private rowElements: HTMLElement[] = []; @@ -281,7 +276,7 @@ abstract class AbstractListSettingWidget extends Dispo protected abstract getContainerClasses(): string[]; protected abstract getActionsForItem(item: TDataItem, idx: number): IAction[]; protected abstract renderItem(item: TDataItem): HTMLElement; - protected abstract renderEdit(item: TDataItem, handlers: IEditHandlers): HTMLElement; + protected abstract renderEdit(item: TDataItem, idx: number): HTMLElement; protected abstract isItemNew(item: TDataItem): boolean; protected abstract getLocalizedRowTitle(item: TDataItem): string; protected abstract getLocalizedStrings(): { @@ -327,9 +322,26 @@ abstract class AbstractListSettingWidget extends Dispo this.renderList(); } + protected cancelEdit(): void { + this.model.setEditKey('none'); + this.renderList(); + } + + protected handleItemChange(originalItem: TDataItem, changedItem: TDataItem, idx: number) { + this.model.setEditKey('none'); + + this._onDidChangeList.fire({ + originalItem, + item: changedItem, + targetIndex: idx, + }); + + this.renderList(); + } + private renderDataOrEditItem(item: IListViewItem, idx: number, listFocused: boolean): HTMLElement { const rowElement = item.editing ? - this.renderEditItem(item, idx) : + this.renderEdit(item, idx) : this.renderDataItem(item, idx, listFocused); rowElement.setAttribute('role', 'listitem'); @@ -357,43 +369,6 @@ abstract class AbstractListSettingWidget extends Dispo return rowElement; } - private renderEditItem(item: IListViewItem, idx: number): HTMLElement { - let rowElement: HTMLElement | undefined; - - const onCancel = () => { - this.model.setEditKey('none'); - this.renderList(); - }; - - const onSubmit = (updatedItem: TDataItem) => { - this.model.setEditKey('none'); - - if (!isUndefinedOrNull(updatedItem)) { - this._onDidChangeList.fire({ - originalItem: item, - item: updatedItem, - targetIndex: idx, - }); - } - - this.renderList(); - }; - - const onKeydown = (e: StandardKeyboardEvent, updatedItem: TDataItem) => { - if (e.equals(KeyCode.Enter)) { - onSubmit(updatedItem); - } else if (e.equals(KeyCode.Escape)) { - onCancel(); - e.preventDefault(); - } - rowElement?.focus(); - }; - - rowElement = this.renderEdit(item, { onSubmit, onKeydown, onCancel }); - - return rowElement; - } - private renderAddButton(): HTMLElement { const rowElement = $('.setting-list-new-row'); @@ -529,7 +504,7 @@ export class ListSettingWidget extends AbstractListSettingWidget return rowElement; } - protected renderEdit(item: IListDataItem, { onKeydown, onSubmit, onCancel }: IEditHandlers): HTMLElement { + protected renderEdit(item: IListDataItem, idx: number): HTMLElement { const rowElement = $('.setting-list-edit-row'); const updatedItem = () => ({ @@ -537,6 +512,16 @@ export class ListSettingWidget extends AbstractListSettingWidget sibling: siblingInput?.value }); + const onKeyDown = (e: StandardKeyboardEvent) => { + if (e.equals(KeyCode.Enter)) { + this.handleItemChange(item, updatedItem(), idx); + } else if (e.equals(KeyCode.Escape)) { + this.cancelEdit(); + e.preventDefault(); + } + rowElement?.focus(); + }; + const valueInput = new InputBox(rowElement, this.contextViewService, { placeholder: this.getLocalizedStrings().inputPlaceholder }); @@ -550,7 +535,9 @@ export class ListSettingWidget extends AbstractListSettingWidget this.listDisposables.add(valueInput); valueInput.value = item.value; - this.listDisposables.add(DOM.addStandardDisposableListener(valueInput.inputElement, DOM.EventType.KEY_DOWN, e => onKeydown(e, updatedItem()))); + this.listDisposables.add( + DOM.addStandardDisposableListener(valueInput.inputElement, DOM.EventType.KEY_DOWN, onKeyDown) + ); let siblingInput: InputBox | undefined; if (!isUndefinedOrNull(item.sibling)) { @@ -566,7 +553,9 @@ export class ListSettingWidget extends AbstractListSettingWidget })); siblingInput.value = item.sibling; - this.listDisposables.add(DOM.addStandardDisposableListener(siblingInput.inputElement, DOM.EventType.KEY_DOWN, e => onKeydown(e, updatedItem()))); + this.listDisposables.add( + DOM.addStandardDisposableListener(siblingInput.inputElement, DOM.EventType.KEY_DOWN, onKeyDown) + ); } const okButton = this._register(new Button(rowElement)); @@ -574,14 +563,14 @@ export class ListSettingWidget extends AbstractListSettingWidget okButton.element.classList.add('setting-list-ok-button'); this.listDisposables.add(attachButtonStyler(okButton, this.themeService)); - this.listDisposables.add(okButton.onDidClick(() => onSubmit(updatedItem()))); + this.listDisposables.add(okButton.onDidClick(() => this.handleItemChange(item, updatedItem(), idx))); const cancelButton = this._register(new Button(rowElement)); cancelButton.label = localize('cancelButton', "Cancel"); cancelButton.element.classList.add('setting-list-cancel-button'); this.listDisposables.add(attachButtonStyler(cancelButton, this.themeService)); - this.listDisposables.add(cancelButton.onDidClick(onCancel)); + this.listDisposables.add(cancelButton.onDidClick(() => this.cancelEdit())); this.listDisposables.add( disposableTimeout(() => { @@ -666,15 +655,30 @@ export interface IObjectDataItem { removable: boolean; } +export interface IObjectValueSuggester { + (key: string): ObjectValue | undefined; +} + interface IObjectSetValueOptions { - showAddButton?: boolean; + showAddButton: boolean; + valueSuggester: IObjectValueSuggester; +} + +interface IObjectRenderEditWidgetOptions { + isKey: boolean; + idx: number; + readonly originalItem: IObjectDataItem; + readonly changedItem: IObjectDataItem; + update(keyOrValue: ObjectKey | ObjectValue): void; } export class ObjectSettingWidget extends AbstractListSettingWidget { private showAddButton: boolean = true; + private valueSuggester: IObjectValueSuggester = () => undefined;; setValue(listData: IObjectDataItem[], options?: IObjectSetValueOptions): void { this.showAddButton = options?.showAddButton ?? this.showAddButton; + this.valueSuggester = options?.valueSuggester ?? this.valueSuggester; super.setValue(listData); } @@ -759,84 +763,76 @@ export class ObjectSettingWidget extends AbstractListSettingWidget): HTMLElement { - const rowElement = $('.setting-list-edit-row'); - rowElement.classList.add('setting-list-object-row'); + protected renderEdit(item: IObjectDataItem, idx: number): HTMLElement { + const rowElement = $('.setting-list-edit-row.setting-list-object-row'); + + const changedItem = { ...item }; + const onKeyChange = (key: ObjectKey) => { + changedItem.key = key; + this.updateValueUsingSuggestion(key.data, item.value, newValue => { + if (this.shouldUseSuggestion(item.value, changedItem.value, newValue)) { + onValueChange(newValue); + renderLatestValue(); + } + }); + }; + const onValueChange = (value: ObjectValue) => { + changedItem.value = value; + }; let keyWidget: InputBox | SelectBox | undefined; + let keyElement: HTMLElement; if (this.showAddButton) { - keyWidget = this.renderEditWidget(item.key, rowElement, true); + const { widget, element } = this.renderEditWidget(item.key, { + idx, + isKey: true, + originalItem: item, + changedItem, + update: onKeyChange, + }); + keyWidget = widget; + keyElement = element; } else { - const keyElement = DOM.append(rowElement, $('.setting-list-object-key')); - keyElement.setAttribute('aria-readonly', 'true'); + keyElement = $('.setting-list-object-key'); keyElement.textContent = item.key.data; } - const valueWidget = this.renderEditWidget(item.value, rowElement, false); + let valueWidget: InputBox | SelectBox; + const valueContainer = $('.setting-list-object-value-container'); - const updatedItem = () => { - const newItem = { ...item }; + const renderLatestValue = () => { + const { widget, element } = this.renderEditWidget(changedItem.value, { + idx, + isKey: false, + originalItem: item, + changedItem, + update: onValueChange, + }); - if (keyWidget instanceof InputBox) { - newItem.key = { type: 'string', data: keyWidget.value }; - } + valueWidget = widget; - if (valueWidget instanceof InputBox) { - newItem.value = { type: 'string', data: valueWidget.value }; - } - - return newItem; + DOM.clearNode(valueContainer); + valueContainer.append(element); }; - if (keyWidget instanceof InputBox) { - keyWidget.setPlaceHolder(this.getLocalizedStrings().keyInputPlaceholder); - this.listDisposables.add(DOM.addStandardDisposableListener(keyWidget.inputElement, DOM.EventType.KEY_DOWN, e => onKeydown(e, updatedItem()))); - } else if (keyWidget instanceof SelectBox) { - this.listDisposables.add( - keyWidget.onDidSelect(({ selected }) => { - const editKey = this.model.items.findIndex(({ key }) => selected === key.data); + renderLatestValue(); - if (editKey >= 0) { - this.model.select(editKey); - this.model.setEditKey(editKey); - this.renderList(); - } else { - onSubmit({ ...item, key: { ...item.key, data: selected } }); - } - }) - ); - } - - if (valueWidget instanceof InputBox) { - valueWidget.setPlaceHolder(this.getLocalizedStrings().valueInputPlaceholder); - this.listDisposables.add(DOM.addStandardDisposableListener(valueWidget.inputElement, DOM.EventType.KEY_DOWN, e => onKeydown(e, updatedItem()))); - } else if (valueWidget instanceof SelectBox) { - this.listDisposables.add( - valueWidget.onDidSelect(({ selected }) => { - onSubmit({ - ...item, - value: item.value.type === 'boolean' - ? { ...item.value, data: selected === 'true' ? true : false } - : { ...item.value, data: selected }, - }); - }) - ); - } + rowElement.append(keyElement, valueContainer); const okButton = this._register(new Button(rowElement)); okButton.label = localize('okButton', "OK"); okButton.element.classList.add('setting-list-ok-button'); this.listDisposables.add(attachButtonStyler(okButton, this.themeService)); - this.listDisposables.add(okButton.onDidClick(() => onSubmit(updatedItem()))); + this.listDisposables.add(okButton.onDidClick(() => this.handleItemChange(item, changedItem, idx))); const cancelButton = this._register(new Button(rowElement)); cancelButton.label = localize('cancelButton', "Cancel"); cancelButton.element.classList.add('setting-list-cancel-button'); this.listDisposables.add(attachButtonStyler(cancelButton, this.themeService)); - this.listDisposables.add(cancelButton.onDidClick(onCancel)); + this.listDisposables.add(cancelButton.onDidClick(() => this.cancelEdit())); this.listDisposables.add( disposableTimeout(() => { @@ -853,6 +849,140 @@ export class ObjectSettingWidget extends AbstractListSettingWidget update({ ...keyOrValue, data: value }))); + + const onKeyDown = (e: StandardKeyboardEvent) => { + if (e.equals(KeyCode.Enter)) { + this.handleItemChange(originalItem, changedItem, idx); + } else if (e.equals(KeyCode.Escape)) { + this.cancelEdit(); + e.preventDefault(); + } + }; + + this.listDisposables.add( + DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_DOWN, onKeyDown) + ); + + return { widget: inputBox, element: wrapper }; + } + + private renderEnumEditWidget( + keyOrValue: IObjectEnumData, + { isKey, originalItem, update }: IObjectRenderEditWidgetOptions, + ) { + const selectBoxOptions = keyOrValue.options.map(({ value, description }) => ({ text: value, description })); + const selected = keyOrValue.options.findIndex(option => keyOrValue.data === option.value); + + const selectBox = new SelectBox(selectBoxOptions, selected, this.contextViewService, undefined, { + useCustomDrawn: !(isIOS && BrowserFeatures.pointerEvents) + }); + + this.listDisposables.add(attachSelectBoxStyler(selectBox, this.themeService, { + selectBackground: settingsSelectBackground, + selectForeground: settingsSelectForeground, + selectBorder: settingsSelectBorder, + selectListBorder: settingsSelectListBorder + })); + + const originalKeyOrValue = isKey ? originalItem.key : originalItem.value; + + this.listDisposables.add( + selectBox.onDidSelect(({ selected }) => + update( + originalKeyOrValue.type === 'boolean' + ? { ...originalKeyOrValue, data: selected === 'true' ? true : false } + : { ...originalKeyOrValue, data: selected }, + ) + ) + ); + + const wrapper = $('.setting-list-object-input'); + wrapper.classList.add( + isKey ? 'setting-list-object-input-key' : 'setting-list-object-input-value', + ); + + selectBox.render(wrapper); + + return { widget: selectBox, element: wrapper }; + } + + @debounce(300) + private updateValueUsingSuggestion(key: string, defaultValue: ObjectValue, onUpdate: (value: ObjectValue) => void) { + const suggestion = this.valueSuggester(key); + onUpdate(suggestion ?? defaultValue); + } + + private shouldUseSuggestion(originalValue: ObjectValue, previousValue: ObjectValue, newValue: ObjectValue): boolean { + if (previousValue === newValue) { + return false; + } + + // item is new, use suggestion + if (originalValue.data === '') { + return true; + } + + if (previousValue.type === newValue.type && newValue.type !== 'enum') { + return false; + } + + // check if all enum options are the same + if (previousValue.type === 'enum' && newValue.type === 'enum') { + const previousEnums = new Set(previousValue.options.map(({ value }) => value)); + newValue.options.forEach(({ value }) => previousEnums.delete(value)); + + // all options are the same + if (previousEnums.size === 0) { + return false; + } + } + + return true; + } + protected getLocalizedRowTitle(item: IObjectDataItem): string { let enumDescription = item.key.type === 'enum' ? item.key.options.find(({ value }) => item.key.data === value)?.description @@ -876,72 +1006,6 @@ export class ObjectSettingWidget extends AbstractListSettingWidget ({ text: value, description })); - const selected = keyOrValue.options.findIndex(option => keyOrValue.data === option.value); - - const selectBox = new SelectBox(selectBoxOptions, selected, this.contextViewService, undefined, { - useCustomDrawn: !(isIOS && BrowserFeatures.pointerEvents) - }); - - this.listDisposables.add(attachSelectBoxStyler(selectBox, this.themeService, { - selectBackground: settingsSelectBackground, - selectForeground: settingsSelectForeground, - selectBorder: settingsSelectBorder, - selectListBorder: settingsSelectListBorder - })); - - const wrapper = $('.setting-list-object-input'); - wrapper.classList.add( - isKey ? 'setting-list-object-input-key' : 'setting-list-object-input-value', - ); - - selectBox.render(wrapper); - rowElement.append(wrapper); - - return selectBox; - } }