Add toggle support for QuickInput/QuickPick, resourceUri support for QuickPickItem (#271598)

* Add quickPickItemResource API proposal

* Transfer resourceUri from extension host to main thread.

* Make proposed API checks consistent.

* Process resourceUri

* Fix up resourceUri mapping logic

* API proposal

* Transfer toggles from extension host to main thread

* Support Folder icon, refactor label/description derivation.

* Update

* Update API proposal per API review

* Update transfer logic per API changes

* Move toggles to the base input interface

* Handle toggle button type

* Fix up

* Updates

* Propagate checked state, dispose removed toggles.

* Nit

* Expand icons

* Feedback/updates

* Added comments, PR feedback

* Updates

* Revert some change, add typings and unit-tests to converters.

* Add a quick pick test for resourceUri

* Test updates
This commit is contained in:
Dmitriy Vasyura
2025-10-18 15:57:07 -07:00
committed by GitHub
parent 8e72c61e03
commit bfba6b040c
14 changed files with 558 additions and 151 deletions

View File

@@ -32,7 +32,9 @@
"notebookMessaging",
"notebookMime",
"portsAttributes",
"quickInputButtonLocation",
"quickPickSortByLabel",
"quickPickItemResource",
"resolvers",
"scmActionButton",
"scmSelectedProvider",

View File

@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import { commands, Disposable, QuickPick, QuickPickItem, window } from 'vscode';
import { commands, Disposable, QuickPick, QuickPickItem, window, workspace } from 'vscode';
import { assertNoRpc, closeAllEditors } from '../utils';
interface QuickPickExpected {
@@ -248,6 +248,71 @@ suite('vscode API - quick input', function () {
quickPick.hide();
await waitForHide(quickPick);
});
test('createQuickPick, match item by label derived from resourceUri', function (_done) {
let done = (err?: any) => {
done = () => { };
_done(err);
};
const quickPick = createQuickPick({
events: ['active', 'selection', 'accept', 'hide'],
activeItems: [['']],
selectionItems: [['']],
acceptedItems: {
active: [['']],
selection: [['']],
dispose: [true]
},
}, (err?: any) => done(err));
const baseUri = workspace!.workspaceFolders![0].uri;
quickPick.items = [
{ label: 'a1', resourceUri: baseUri.with({ path: baseUri.path + '/test1.txt' }) },
{ label: '', resourceUri: baseUri.with({ path: baseUri.path + '/test2.txt' }) },
{ label: 'a3', resourceUri: baseUri.with({ path: baseUri.path + '/test3.txt' }) }
];
quickPick.value = 'test2.txt';
quickPick.show();
(async () => {
await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem');
})()
.catch(err => done(err));
});
test('createQuickPick, match item by description derived from resourceUri', function (_done) {
let done = (err?: any) => {
done = () => { };
_done(err);
};
const quickPick = createQuickPick({
events: ['active', 'selection', 'accept', 'hide'],
activeItems: [['a2']],
selectionItems: [['a2']],
acceptedItems: {
active: [['a2']],
selection: [['a2']],
dispose: [true]
},
}, (err?: any) => done(err));
const baseUri = workspace!.workspaceFolders![0].uri;
quickPick.items = [
{ label: 'a1', resourceUri: baseUri.with({ path: baseUri.path + '/test1.txt' }) },
{ label: 'a2', resourceUri: baseUri.with({ path: baseUri.path + '/test2.txt' }) },
{ label: 'a3', resourceUri: baseUri.with({ path: baseUri.path + '/test3.txt' }) }
];
quickPick.matchOnDescription = true;
quickPick.value = 'test2.txt';
quickPick.show();
(async () => {
await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem');
})()
.catch(err => done(err));
});
});
function createQuickPick(expected: QuickPickExpected, done: (err?: any) => void, record = false) {

View File

@@ -101,4 +101,17 @@ export namespace ThemeIcon {
return ti1.id === ti2.id && ti1.color?.id === ti2.color?.id;
}
/**
* Returns whether specified icon is defined and has 'file' ID.
*/
export function isFile(icon: ThemeIcon | undefined): boolean {
return icon?.id === Codicon.file.id;
}
/**
* Returns whether specified icon is defined and has 'folder' ID.
*/
export function isFolder(icon: ThemeIcon | undefined): boolean {
return icon?.id === Codicon.folder.id;
}
}

View File

@@ -328,6 +328,9 @@ const _allApiProposals = {
quickInputButtonLocation: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.quickInputButtonLocation.d.ts',
},
quickPickItemResource: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.quickPickItemResource.d.ts',
},
quickPickItemTooltip: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.quickPickItemTooltip.d.ts',
},

View File

@@ -359,6 +359,11 @@ export interface IQuickInput extends IDisposable {
*/
ignoreFocusOut: boolean;
/**
* The toggle buttons to be added to the input box.
*/
toggles: IQuickInputToggle[] | undefined;
/**
* Shows the quick input.
*/
@@ -694,11 +699,6 @@ export interface IQuickPick<T extends IQuickPickItem, O extends { useSeparators:
*/
hideCheckAll: boolean;
/**
* The toggle buttons to be added to the input box.
*/
toggles: IQuickInputToggle[] | undefined;
/**
* Focus a particular item in the list. Used internally for keyboard navigation.
* @param focus The focus behavior.
@@ -794,7 +794,13 @@ export enum QuickInputButtonLocation {
/**
* To the right of the input box.
*/
Inline = 2
Inline = 2,
/**
* At the far end inside the input box.
* Used by the public API to create toggles.
*/
Input = 3,
}
/**

View File

@@ -3,26 +3,30 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IPickOptions, IInputOptions, IQuickInputService, IQuickInput, IQuickPick, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js';
import { ExtHostContext, MainThreadQuickOpenShape, ExtHostQuickOpenShape, TransferQuickPickItem, MainContext, TransferQuickInput, TransferQuickInputButton, IInputBoxOptions, TransferQuickPickItemOrSeparator } from '../common/extHost.protocol.js';
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { URI } from '../../../base/common/uri.js';
import { Toggle } from '../../../base/browser/ui/toggle/toggle.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { DisposableStore } from '../../../base/common/lifecycle.js';
import { Lazy } from '../../../base/common/lazy.js';
import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
import { basenameOrAuthority, dirname } from '../../../base/common/resources.js';
import { ThemeIcon } from '../../../base/common/themables.js';
import { isUriComponents, URI } from '../../../base/common/uri.js';
import { ILanguageService } from '../../../editor/common/languages/language.js';
import { getIconClasses } from '../../../editor/common/services/getIconClasses.js';
import { IModelService } from '../../../editor/common/services/model.js';
import { ILabelService } from '../../../platform/label/common/label.js';
import { IInputOptions, IPickOptions, IQuickInput, IQuickInputService, IQuickPick, IQuickPickItem, QuickInputButtonLocation } from '../../../platform/quickinput/common/quickInput.js';
import { asCssVariable, inputActiveOptionBackground, inputActiveOptionBorder, inputActiveOptionForeground } from '../../../platform/theme/common/colorRegistry.js';
import { ICustomEditorLabelService } from '../../services/editor/common/customEditorLabelService.js';
import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js';
import { ExtHostContext, ExtHostQuickOpenShape, IInputBoxOptions, MainContext, MainThreadQuickOpenShape, TransferQuickInput, TransferQuickInputButton, TransferQuickPickItem, TransferQuickPickItemOrSeparator } from '../common/extHost.protocol.js';
interface QuickInputSession {
input: IQuickInput;
handlesToItems: Map<number, TransferQuickPickItem>;
handlesToToggles: Map<number, { toggle: Toggle; listener: IDisposable }>;
store: DisposableStore;
}
function reviveIconPathUris(iconPath: { dark: URI; light?: URI | undefined }) {
iconPath.dark = URI.revive(iconPath.dark);
if (iconPath.light) {
iconPath.light = URI.revive(iconPath.light);
}
}
@extHostNamedCustomer(MainContext.MainThreadQuickOpen)
export class MainThreadQuickOpen implements MainThreadQuickOpenShape {
@@ -35,7 +39,11 @@ export class MainThreadQuickOpen implements MainThreadQuickOpenShape {
constructor(
extHostContext: IExtHostContext,
@IQuickInputService quickInputService: IQuickInputService
@IQuickInputService quickInputService: IQuickInputService,
@ILabelService private readonly labelService: ILabelService,
@ICustomEditorLabelService private readonly customEditorLabelService: ICustomEditorLabelService,
@IModelService private readonly modelService: IModelService,
@ILanguageService private readonly languageService: ILanguageService
) {
this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostQuickOpen);
this._quickInputService = quickInputService;
@@ -80,6 +88,7 @@ export class MainThreadQuickOpen implements MainThreadQuickOpenShape {
$setItems(instance: number, items: TransferQuickPickItemOrSeparator[]): Promise<void> {
if (this._items[instance]) {
items.forEach(item => this.expandItemProps(item));
this._items[instance].resolve(items);
delete this._items[instance];
}
@@ -159,62 +168,79 @@ export class MainThreadQuickOpen implements MainThreadQuickOpenShape {
session = {
input,
handlesToItems: new Map(),
handlesToToggles: new Map(),
store
};
this.sessions.set(sessionId, session);
}
const { input, handlesToItems } = session;
const quickPick = input as IQuickPick<IQuickPickItem>;
for (const param in params) {
if (param === 'id' || param === 'type') {
continue;
}
if (param === 'visible') {
if (params.visible) {
input.show();
} else {
input.hide();
switch (param) {
case 'id':
case 'type':
continue;
case 'visible':
if (params.visible) {
input.show();
} else {
input.hide();
}
break;
case 'items': {
handlesToItems.clear();
params.items?.forEach((item: TransferQuickPickItemOrSeparator) => {
this.expandItemProps(item);
if (item.type !== 'separator') {
item.buttons?.forEach(button => this.expandIconPath(button));
handlesToItems.set(item.handle, item);
}
});
quickPick.items = params.items;
break;
}
} else if (param === 'items') {
handlesToItems.clear();
params[param].forEach((item: TransferQuickPickItemOrSeparator) => {
if (item.type === 'separator') {
return;
}
if (item.buttons) {
item.buttons = item.buttons.map((button: TransferQuickInputButton) => {
if (button.iconPath) {
reviveIconPathUris(button.iconPath);
case 'activeItems':
quickPick.activeItems = params.activeItems
?.map((handle: number) => handlesToItems.get(handle))
.filter(Boolean);
break;
case 'selectedItems':
quickPick.selectedItems = params.selectedItems
?.map((handle: number) => handlesToItems.get(handle))
.filter(Boolean);
break;
case 'buttons': {
const buttons = [], toggles = [];
for (const button of params.buttons!) {
if (button.handle === -1) {
buttons.push(this._quickInputService.backButton);
} else {
this.expandIconPath(button);
// Currently buttons are only supported outside of the input box
// and toggles only inside. When/if that changes, this will need to be updated.
if (button.location === QuickInputButtonLocation.Input) {
toggles.push(button);
} else {
buttons.push(button);
}
return button;
});
}
handlesToItems.set(item.handle, item);
});
// eslint-disable-next-line local/code-no-any-casts
(input as any)[param] = params[param];
} else if (param === 'activeItems' || param === 'selectedItems') {
// eslint-disable-next-line local/code-no-any-casts
(input as any)[param] = params[param]
.filter((handle: number) => handlesToItems.has(handle))
.map((handle: number) => handlesToItems.get(handle));
} else if (param === 'buttons') {
// eslint-disable-next-line local/code-no-any-casts
(input as any)[param] = params.buttons!.map(button => {
if (button.handle === -1) {
return this._quickInputService.backButton;
}
}
input.buttons = buttons;
this.updateToggles(sessionId, session, toggles);
break;
}
if (button.iconPath) {
reviveIconPathUris(button.iconPath);
}
return button;
});
} else {
// eslint-disable-next-line local/code-no-any-casts
(input as any)[param] = params[param];
default:
// eslint-disable-next-line local/code-no-any-casts
(input as any)[param] = params[param];
break;
}
}
return Promise.resolve(undefined);
@@ -228,4 +254,114 @@ export class MainThreadQuickOpen implements MainThreadQuickOpenShape {
}
return Promise.resolve(undefined);
}
/**
* Derives icon, label and description for Quick Pick items that represent a resource URI.
*/
private expandItemProps(item: TransferQuickPickItemOrSeparator) {
if (item.type === 'separator') {
return;
}
if (!item.resourceUri) {
this.expandIconPath(item);
return;
}
// Derive missing label and description from resourceUri.
const resourceUri = URI.from(item.resourceUri);
item.label ??= this.customEditorLabelService.getName(resourceUri) || '';
if (item.label) {
item.description ??= this.labelService.getUriLabel(resourceUri, { relative: true });
} else {
item.label = basenameOrAuthority(resourceUri);
item.description ??= this.labelService.getUriLabel(dirname(resourceUri), { relative: true });
}
// Derive icon props from resourceUri if icon is set to ThemeIcon.File or ThemeIcon.Folder.
const icon = item.iconPathDto;
if (ThemeIcon.isThemeIcon(icon) && (ThemeIcon.isFile(icon) || ThemeIcon.isFolder(icon))) {
const iconClasses = new Lazy(() => getIconClasses(this.modelService, this.languageService, resourceUri));
Object.defineProperty(item, 'iconClasses', { get: () => iconClasses.value });
} else {
this.expandIconPath(item);
}
}
/**
* Converts IconPath DTO into iconPath/iconClass properties.
*/
private expandIconPath(target: Pick<TransferQuickPickItem, 'iconPathDto' | 'iconPath' | 'iconClass'>) {
const icon = target.iconPathDto;
if (!icon) {
return;
} else if (ThemeIcon.isThemeIcon(icon)) {
// TODO: Since IQuickPickItem and IQuickInputButton do not support ThemeIcon directly, the color ID is lost here.
// We should consider changing changing iconPath/iconClass to IconPath in both interfaces.
// Request for color support: https://github.com/microsoft/vscode/issues/185356..
target.iconClass = ThemeIcon.asClassName(icon);
} else if (isUriComponents(icon)) {
const uri = URI.from(icon);
target.iconPath = { dark: uri, light: uri };
} else {
const { dark, light } = icon;
target.iconPath = { dark: URI.from(dark), light: URI.from(light) };
}
}
/**
* Updates the toggles for a given quick input session by creating new {@link Toggle}-s
* from buttons, updating existing toggles props and removing old ones.
*/
private updateToggles(sessionId: number, session: QuickInputSession, buttons: TransferQuickInputButton[]) {
const { input, handlesToToggles, store } = session;
// Add new or update existing toggles.
const toggles = [];
for (const button of buttons) {
const title = button.tooltip || '';
const isChecked = !!button.checked;
// TODO: Toggle class only supports ThemeIcon at the moment, but not other formats of IconPath.
// We should consider adding support for the full IconPath to Toggle, in this code should be updated.
const icon = ThemeIcon.isThemeIcon(button.iconPathDto) ? button.iconPathDto : undefined;
let { toggle } = handlesToToggles.get(button.handle) || {};
if (toggle) {
// Toggle already exists, update its props.
toggle.setTitle(title);
toggle.setIcon(icon);
toggle.checked = isChecked;
} else {
// Create a new toggle from the button.
toggle = store.add(new Toggle({
title,
icon,
isChecked,
inputActiveOptionBorder: asCssVariable(inputActiveOptionBorder),
inputActiveOptionForeground: asCssVariable(inputActiveOptionForeground),
inputActiveOptionBackground: asCssVariable(inputActiveOptionBackground)
}));
const listener = store.add(toggle.onChange(() => {
this._proxy.$onDidTriggerButton(sessionId, button.handle, toggle!.checked);
}));
handlesToToggles.set(button.handle, { toggle, listener });
}
toggles.push(toggle);
}
// Remove toggles that are no longer present from the session map.
for (const [handle, { toggle, listener }] of handlesToToggles) {
if (!buttons.some(button => button.handle === handle)) {
handlesToToggles.delete(handle);
store.delete(toggle);
store.delete(listener);
}
}
// Update toggle interfaces on the input widget.
input.toggles = toggles;
}
}

View File

@@ -98,6 +98,11 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js';
import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js';
import * as tasks from './shared/tasks.js';
export type IconPathDto =
| UriComponents
| { light: UriComponents; dark: UriComponents }
| ThemeIcon;
export interface IWorkspaceData extends IStaticWorkspaceData {
folders: { uri: UriComponents; name: string; index: number }[];
}
@@ -614,17 +619,29 @@ export interface TransferQuickPickItem {
// shared properties from IQuickPickItem
type?: 'item';
label: string;
iconPath?: { light?: URI; dark: URI };
iconClass?: string;
iconPathDto?: IconPathDto;
description?: string;
detail?: string;
picked?: boolean;
alwaysShow?: boolean;
buttons?: TransferQuickInputButton[];
resourceUri?: UriComponents;
// TODO: These properties are not used for transfer (iconPathDto is used instead) but they cannot be removed
// because this type is used as IQuickPickItem on the main thread. Ideally IQuickPickItem should also use IconPath.
iconPath?: { light?: URI; dark: URI };
iconClass?: string;
}
export interface TransferQuickInputButton extends quickInput.IQuickInputButton {
handle: number;
iconPathDto: IconPathDto;
checked?: boolean;
// TODO: These properties are not used for transfer (iconPathDto is used instead) but they cannot be removed
// because this type is used as IQuickInputButton on the main thread. Ideally IQuickInputButton should also use IconPath.
iconPath?: { light?: URI; dark: URI };
iconClass?: string;
}
export type TransferQuickInput = TransferQuickPick | TransferInputBox;
@@ -1635,7 +1652,7 @@ export interface SCMHistoryItemRefDto {
readonly revision?: string;
readonly category?: string;
readonly description?: string;
readonly icon?: UriComponents | { light: UriComponents; dark: UriComponents } | ThemeIcon;
readonly icon?: IconPathDto;
}
export interface SCMHistoryItemRefsChangeEventDto {
@@ -1652,7 +1669,7 @@ export interface SCMHistoryItemDto {
readonly message: string;
readonly displayId?: string;
readonly author?: string;
readonly authorIcon?: UriComponents | { light: UriComponents; dark: UriComponents } | ThemeIcon;
readonly authorIcon?: IconPathDto;
readonly authorEmail?: string;
readonly timestamp?: number;
readonly statistics?: {
@@ -1671,7 +1688,7 @@ export interface SCMHistoryItemChangeDto {
}
export interface MainThreadSCMShape extends IDisposable {
$registerSourceControl(handle: number, parentHandle: number | undefined, id: string, label: string, rootUri: UriComponents | undefined, iconPath: UriComponents | { light: UriComponents; dark: UriComponents } | ThemeIcon | undefined, inputBoxDocumentUri: UriComponents): Promise<void>;
$registerSourceControl(handle: number, parentHandle: number | undefined, id: string, label: string, rootUri: UriComponents | undefined, iconPath: IconPathDto | undefined, inputBoxDocumentUri: UriComponents): Promise<void>;
$updateSourceControl(handle: number, features: SCMProviderFeatures): Promise<void>;
$unregisterSourceControl(handle: number): Promise<void>;
@@ -2198,7 +2215,7 @@ export interface IWorkspaceEditEntryMetadataDto {
needsConfirmation: boolean;
label: string;
description?: string;
iconPath?: { id: string } | UriComponents | { light: UriComponents; dark: UriComponents };
iconPath?: IconPathDto;
}
export interface IChatNotebookEditDto {
@@ -2423,7 +2440,7 @@ export interface ExtHostQuickOpenShape {
$onDidChangeSelection(sessionId: number, handles: number[]): void;
$onDidAccept(sessionId: number): void;
$onDidChangeValue(sessionId: number, value: string): void;
$onDidTriggerButton(sessionId: number, handle: number): void;
$onDidTriggerButton(sessionId: number, handle: number, checked?: boolean): void;
$onDidTriggerItemButton(sessionId: number, itemHandle: number, buttonHandle: number): void;
$onDidHide(sessionId: number): void;
}

View File

@@ -10,15 +10,13 @@ import { ExtHostCommands } from './extHostCommands.js';
import { IExtHostWorkspaceProvider } from './extHostWorkspace.js';
import { InputBox, InputBoxOptions, InputBoxValidationMessage, QuickInput, QuickInputButton, QuickPick, QuickPickItem, QuickPickItemButtonEvent, QuickPickOptions, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode';
import { ExtHostQuickOpenShape, IMainContext, MainContext, TransferQuickInput, TransferQuickInputButton, TransferQuickPickItemOrSeparator } from './extHost.protocol.js';
import { URI } from '../../../base/common/uri.js';
import { ThemeIcon, QuickInputButtons, QuickPickItemKind, InputBoxValidationSeverity } from './extHostTypes.js';
import { QuickInputButtons, QuickPickItemKind, InputBoxValidationSeverity } from './extHostTypes.js';
import { isCancellationError } from '../../../base/common/errors.js';
import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js';
import { coalesce } from '../../../base/common/arrays.js';
import Severity from '../../../base/common/severity.js';
import { ThemeIcon as ThemeIconUtils } from '../../../base/common/themables.js';
import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js';
import { MarkdownString } from './extHostTypeConverters.js';
import { checkProposedApiEnabled } from '../../services/extensions/common/extensions.js';
import { IconPath, MarkdownString } from './extHostTypeConverters.js';
export type Item = string | QuickPickItem;
@@ -90,8 +88,6 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
return undefined;
}
const allowedTooltips = isProposedApiEnabled(extension, 'quickPickItemTooltip');
return itemsPromise.then(items => {
const pickItems: TransferQuickPickItemOrSeparator[] = [];
@@ -102,20 +98,23 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
} else if (item.kind === QuickPickItemKind.Separator) {
pickItems.push({ type: 'separator', label: item.label });
} else {
if (item.tooltip && !allowedTooltips) {
console.warn(`Extension '${extension.identifier.value}' uses a tooltip which is proposed API that is only available when running out of dev or with the following command line switch: --enable-proposed-api ${extension.identifier.value}`);
if (item.tooltip) {
checkProposedApiEnabled(extension, 'quickPickItemTooltip');
}
if (item.resourceUri) {
checkProposedApiEnabled(extension, 'quickPickItemResource');
}
const icon = (item.iconPath) ? getIconPathOrClass(item.iconPath) : undefined;
pickItems.push({
label: item.label,
iconPath: icon?.iconPath,
iconClass: icon?.iconClass,
iconPathDto: IconPath.from(item.iconPath),
description: item.description,
detail: item.detail,
picked: item.picked,
alwaysShow: item.alwaysShow,
tooltip: allowedTooltips ? MarkdownString.fromStrict(item.tooltip) : undefined,
tooltip: MarkdownString.fromStrict(item.tooltip),
resourceUri: item.resourceUri,
handle
});
}
@@ -256,9 +255,9 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
}
}
$onDidTriggerButton(sessionId: number, handle: number): void {
$onDidTriggerButton(sessionId: number, handle: number, checked?: boolean): void {
const session = this._sessions.get(sessionId);
session?._fireDidTriggerButton(handle);
session?._fireDidTriggerButton(handle, checked);
}
$onDidTriggerItemButton(sessionId: number, itemHandle: number, buttonHandle: number): void {
@@ -400,9 +399,8 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
}
set buttons(buttons: QuickInputButton[]) {
const allowedButtonLocation = isProposedApiEnabled(this._extension, 'quickInputButtonLocation');
if (!allowedButtonLocation && buttons.some(button => button.location)) {
console.warn(`Extension '${this._extension.identifier.value}' uses a button location which is proposed API that is only available when running out of dev or with the following command line switch: --enable-proposed-api ${this._extension.identifier.value}`);
if (buttons.some(button => button.location || button.checked !== undefined)) {
checkProposedApiEnabled(this._extension, 'quickInputButtonLocation');
}
this._buttons = buttons.slice();
this._handlesToButtons.clear();
@@ -413,10 +411,11 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
this.update({
buttons: buttons.map<TransferQuickInputButton>((button, i) => {
return {
...getIconPathOrClass(button.iconPath),
iconPathDto: IconPath.from(button.iconPath),
tooltip: button.tooltip,
handle: button === QuickInputButtons.Back ? -1 : i,
location: allowedButtonLocation ? button.location : undefined
location: button.location,
checked: button.checked
};
})
});
@@ -446,9 +445,12 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
this._onDidChangeValueEmitter.fire(value);
}
_fireDidTriggerButton(handle: number) {
_fireDidTriggerButton(handle: number, checked?: boolean) {
const button = this._handlesToButtons.get(handle);
if (button) {
if (checked !== undefined) {
button.checked = checked;
}
this._onDidTriggerButtonEmitter.fire(button);
}
}
@@ -499,7 +501,7 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
}
this.dispatchUpdate();
} else if (this._visible && !this._updateTimeout) {
// Defer the update so that multiple changes to setters dont cause a redraw each
// Defer the update so that multiple changes to setters don't cause a redraw each
this._updateTimeout = setTimeout(() => {
this._updateTimeout = undefined;
this.dispatchUpdate();
@@ -513,43 +515,6 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
}
}
function getIconUris(iconPath: QuickInputButton['iconPath']): { dark: URI; light?: URI } | { id: string } {
if (iconPath instanceof ThemeIcon) {
return { id: iconPath.id };
}
const dark = getDarkIconUri(iconPath as URI | { light: URI; dark: URI });
const light = getLightIconUri(iconPath as URI | { light: URI; dark: URI });
// Tolerate strings: https://github.com/microsoft/vscode/issues/110432#issuecomment-726144556
return {
dark: typeof dark === 'string' ? URI.file(dark) : dark,
light: typeof light === 'string' ? URI.file(light) : light
};
}
function getLightIconUri(iconPath: URI | { light: URI; dark: URI }) {
return typeof iconPath === 'object' && 'light' in iconPath ? iconPath.light : iconPath;
}
function getDarkIconUri(iconPath: URI | { light: URI; dark: URI }) {
return typeof iconPath === 'object' && 'dark' in iconPath ? iconPath.dark : iconPath;
}
function getIconPathOrClass(icon: QuickInputButton['iconPath']) {
const iconPathOrIconClass = getIconUris(icon);
let iconPath: { dark: URI; light?: URI | undefined } | undefined;
let iconClass: string | undefined;
if ('id' in iconPathOrIconClass) {
iconClass = ThemeIconUtils.asClassName(iconPathOrIconClass);
} else {
iconPath = iconPathOrIconClass;
}
return {
iconPath,
iconClass
};
}
class ExtHostQuickPick<T extends QuickPickItem> extends ExtHostQuickInput implements QuickPick<T> {
private _items: T[] = [];
@@ -590,32 +555,33 @@ export function createExtHostQuickOpen(mainContext: IMainContext, workspace: IEx
this._itemsToHandles.set(item, i);
});
const allowedTooltips = isProposedApiEnabled(this._extension, 'quickPickItemTooltip');
const pickItems: TransferQuickPickItemOrSeparator[] = [];
for (let handle = 0; handle < items.length; handle++) {
const item = items[handle];
if (item.kind === QuickPickItemKind.Separator) {
pickItems.push({ type: 'separator', label: item.label });
} else {
if (item.tooltip && !allowedTooltips) {
console.warn(`Extension '${this._extension.identifier.value}' uses a tooltip which is proposed API that is only available when running out of dev or with the following command line switch: --enable-proposed-api ${this._extension.identifier.value}`);
if (item.tooltip) {
checkProposedApiEnabled(this._extension, 'quickPickItemTooltip');
}
if (item.resourceUri) {
checkProposedApiEnabled(this._extension, 'quickPickItemResource');
}
const icon = (item.iconPath) ? getIconPathOrClass(item.iconPath) : undefined;
pickItems.push({
handle,
label: item.label,
iconPath: icon?.iconPath,
iconClass: icon?.iconClass,
iconPathDto: IconPath.from(item.iconPath),
description: item.description,
detail: item.detail,
picked: item.picked,
alwaysShow: item.alwaysShow,
tooltip: allowedTooltips ? MarkdownString.fromStrict(item.tooltip) : undefined,
tooltip: MarkdownString.fromStrict(item.tooltip),
resourceUri: item.resourceUri,
buttons: item.buttons?.map<TransferQuickInputButton>((button, i) => {
return {
...getIconPathOrClass(button.iconPath),
iconPathDto: IconPath.from(button.iconPath),
tooltip: button.tooltip,
handle: i
};

View File

@@ -3732,6 +3732,56 @@ export namespace IconPath {
export function fromThemeIcon(iconPath: vscode.ThemeIcon): languages.IconPath {
return iconPath;
}
/**
* Converts a {@link vscode.IconPath} to an {@link extHostProtocol.IconPathDto}.
* @note This function will tolerate strings specified instead of URIs in IconPath for historical reasons.
* Such strings are treated as file paths and converted using {@link URI.file} function, not {@link URI.from}.
* See https://github.com/microsoft/vscode/issues/110432#issuecomment-726144556 for context.
*/
export function from(value: undefined): undefined;
export function from(value: vscode.IconPath): extHostProtocol.IconPathDto;
export function from(value: vscode.IconPath | undefined): extHostProtocol.IconPathDto | undefined;
export function from(value: vscode.IconPath | undefined): extHostProtocol.IconPathDto | undefined {
if (!value) {
return undefined;
} else if (ThemeIcon.isThemeIcon(value)) {
return value;
} else if (URI.isUri(value)) {
return value;
} else if (typeof value === 'string') {
return URI.file(value);
} else if (typeof value === 'object' && value !== null && 'dark' in value) {
const dark = typeof value.dark === 'string' ? URI.file(value.dark) : value.dark;
const light = typeof value.light === 'string' ? URI.file(value.light) : value.light;
return !dark ? undefined : { dark, light: light ?? dark };
} else {
return undefined;
}
}
/**
* Converts a {@link extHostProtocol.IconPathDto} to a {@link vscode.IconPath}.
* @note This is a strict conversion and we assume types are correct in this case.
*/
export function to(value: undefined): undefined;
export function to(value: extHostProtocol.IconPathDto): vscode.IconPath;
export function to(value: extHostProtocol.IconPathDto | undefined): vscode.IconPath | undefined;
export function to(value: extHostProtocol.IconPathDto | undefined): vscode.IconPath | undefined {
if (!value) {
return undefined;
} else if (ThemeIcon.isThemeIcon(value)) {
return value;
} else if (isUriComponents(value)) {
return URI.revive(value);
} else {
const icon = value as { light: UriComponents; dark: UriComponents };
return {
light: URI.revive(icon.light),
dark: URI.revive(icon.dark)
};
}
}
}
export namespace AiSettingsSearch {

View File

@@ -2544,7 +2544,8 @@ export class DebugVisualization {
export enum QuickInputButtonLocation {
Title = 1,
Inline = 2
Inline = 2,
Input = 3
}
@es5ClassCompat

View File

@@ -0,0 +1,123 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { URI, UriComponents } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { IconPathDto } from '../../common/extHost.protocol.js';
import { IconPath } from '../../common/extHostTypeConverters.js';
import { ThemeColor, ThemeIcon } from '../../common/extHostTypes.js';
suite('extHostTypeConverters', function () {
ensureNoDisposablesAreLeakedInTestSuite();
suite('IconPath', function () {
suite('from', function () {
test('undefined', function () {
assert.strictEqual(IconPath.from(undefined), undefined);
});
test('ThemeIcon', function () {
const themeIcon = new ThemeIcon('account', new ThemeColor('testing.iconForeground'));
assert.strictEqual(IconPath.from(themeIcon), themeIcon);
});
test('URI', function () {
const uri = URI.parse('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
assert.strictEqual(IconPath.from(uri), uri);
});
test('string', function () {
const str = '/path/to/icon.png';
// eslint-disable-next-line local/code-no-any-casts
const r1 = IconPath.from(str as any) as any as URI;
assert.ok(URI.isUri(r1));
assert.strictEqual(r1.scheme, 'file');
assert.strictEqual(r1.path, str);
});
test('dark only', function () {
const input = { dark: URI.file('/path/to/dark.png') };
// eslint-disable-next-line local/code-no-any-casts
const result = IconPath.from(input as any) as unknown as { dark: URI; light: URI };
assert.strictEqual(typeof result, 'object');
assert.ok('light' in result && 'dark' in result);
assert.ok(URI.isUri(result.light));
assert.ok(URI.isUri(result.dark));
assert.strictEqual(result.dark.toString(), input.dark.toString());
assert.strictEqual(result.light.toString(), input.dark.toString());
});
test('dark/light', function () {
const input = { light: URI.file('/path/to/light.png'), dark: URI.file('/path/to/dark.png') };
const result = IconPath.from(input);
assert.strictEqual(typeof result, 'object');
assert.ok('light' in result && 'dark' in result);
assert.ok(URI.isUri(result.light));
assert.ok(URI.isUri(result.dark));
assert.strictEqual(result.dark.toString(), input.dark.toString());
assert.strictEqual(result.light.toString(), input.light.toString());
});
test('dark/light strings', function () {
const input = { light: '/path/to/light.png', dark: '/path/to/dark.png' };
// eslint-disable-next-line local/code-no-any-casts
const result = IconPath.from(input as any) as unknown as IconPathDto;
assert.strictEqual(typeof result, 'object');
assert.ok('light' in result && 'dark' in result);
assert.ok(URI.isUri(result.light));
assert.ok(URI.isUri(result.dark));
assert.strictEqual(result.dark.path, input.dark);
assert.strictEqual(result.light.path, input.light);
});
test('invalid object', function () {
const invalidObject = { foo: 'bar' };
// eslint-disable-next-line local/code-no-any-casts
const result = IconPath.from(invalidObject as any);
assert.strictEqual(result, undefined);
});
test('light only', function () {
const input = { light: URI.file('/path/to/light.png') };
// eslint-disable-next-line local/code-no-any-casts
const result = IconPath.from(input as any);
assert.strictEqual(result, undefined);
});
});
suite('to', function () {
test('undefined', function () {
assert.strictEqual(IconPath.to(undefined), undefined);
});
test('ThemeIcon', function () {
const themeIcon = new ThemeIcon('account');
assert.strictEqual(IconPath.to(themeIcon), themeIcon);
});
test('URI', function () {
const uri: UriComponents = { scheme: 'data', path: 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' };
const result = IconPath.to(uri);
assert.ok(URI.isUri(result));
assert.strictEqual(result.toString(), URI.revive(uri).toString());
});
test('dark/light', function () {
const input: { light: UriComponents; dark: UriComponents } = {
light: { scheme: 'file', path: '/path/to/light.png' },
dark: { scheme: 'file', path: '/path/to/dark.png' }
};
const result = IconPath.to(input);
assert.strictEqual(typeof result, 'object');
assert.ok('light' in result && 'dark' in result);
assert.ok(URI.isUri(result.light));
assert.ok(URI.isUri(result.dark));
assert.strictEqual(result.dark.toString(), URI.revive(input.dark).toString());
assert.strictEqual(result.light.toString(), URI.revive(input.light).toString());
});
});
});
});

View File

@@ -1475,16 +1475,8 @@ class TreeRenderer extends Disposable implements ITreeRenderer<ITreeItem, FuzzyS
return !(hasResource && this.isFileKindThemeIcon(icon));
}
private isFolderThemeIcon(icon: ThemeIcon | undefined): boolean {
return icon?.id === FolderThemeIcon.id;
}
private isFileKindThemeIcon(icon: ThemeIcon | undefined): boolean {
if (icon) {
return icon.id === FileThemeIcon.id || this.isFolderThemeIcon(icon);
} else {
return false;
}
return ThemeIcon.isFile(icon) || ThemeIcon.isFolder(icon);
}
private getFileKind(node: ITreeItem): FileKind {

View File

@@ -16,7 +16,12 @@ declare module 'vscode' {
/**
* To the right of the input box.
*/
Inline = 2
Inline = 2,
/**
* At the far end inside the input box.
*/
Input = 3
}
export interface QuickInputButton {
@@ -25,5 +30,12 @@ declare module 'vscode' {
* @note This property is ignored if the button was added to a QuickPickItem.
*/
location?: QuickInputButtonLocation;
/**
* Indicates whether the button is checked.
* @note This property is currently only applicable to buttons with location {@link QuickInputButtonLocation.Input}.
* It must be set for such buttons and will be updated when the button is toggled.
*/
checked?: boolean;
}
}

View File

@@ -0,0 +1,21 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
declare module 'vscode' {
// https://github.com/microsoft/vscode/issues/59826
export interface QuickPickItem {
/**
* The {@link Uri} of the resource representing this item.
*
* Will be used to derive the {@link label}, when it is not provided (falsy or empty).
* Will be used to derive the {@link description}, when it is not provided (falsy or empty).
* Will be used to derive the icon from current file icon theme, when {@link iconPath} has either
* {@link ThemeIcon.File} or {@link ThemeIcon.Folder} value.
*/
resourceUri?: Uri;
}
}