From bfba6b040c556dc7baef5e325eadfe7ebbbdd62b Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 18 Oct 2025 15:57:07 -0700 Subject: [PATCH] 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 --- extensions/vscode-api-tests/package.json | 2 + .../src/singlefolder-tests/quickInput.test.ts | 67 ++++- src/vs/base/common/themables.ts | 13 + .../common/extensionsApiProposals.ts | 3 + .../platform/quickinput/common/quickInput.ts | 18 +- .../api/browser/mainThreadQuickOpen.ts | 252 ++++++++++++++---- .../workbench/api/common/extHost.protocol.ts | 31 ++- .../workbench/api/common/extHostQuickOpen.ts | 102 +++---- .../api/common/extHostTypeConverters.ts | 50 ++++ src/vs/workbench/api/common/extHostTypes.ts | 3 +- .../test/common/extHostTypeConverters.test.ts | 123 +++++++++ .../workbench/browser/parts/views/treeView.ts | 10 +- ...ode.proposed.quickInputButtonLocation.d.ts | 14 +- ...vscode.proposed.quickPickItemResource.d.ts | 21 ++ 14 files changed, 558 insertions(+), 151 deletions(-) create mode 100644 src/vs/workbench/api/test/common/extHostTypeConverters.test.ts create mode 100644 src/vscode-dts/vscode.proposed.quickPickItemResource.d.ts diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index aaad54485df..10eb7e68fe9 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -32,7 +32,9 @@ "notebookMessaging", "notebookMime", "portsAttributes", + "quickInputButtonLocation", "quickPickSortByLabel", + "quickPickItemResource", "resolvers", "scmActionButton", "scmSelectedProvider", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts index 4f8331c286f..485e3d85f34 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/quickInput.test.ts @@ -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) { diff --git a/src/vs/base/common/themables.ts b/src/vs/base/common/themables.ts index 6b2551bebb2..dcc1369e593 100644 --- a/src/vs/base/common/themables.ts +++ b/src/vs/base/common/themables.ts @@ -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; + } } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 3eec8979844..cae07b0fd1b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -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', }, diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 3716a235669..bafa97203bf 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -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; + handlesToToggles: Map; 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 { 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; 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) { + 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; + } } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 497ed675745..8c1fc131190 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -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; + $registerSourceControl(handle: number, parentHandle: number | undefined, id: string, label: string, rootUri: UriComponents | undefined, iconPath: IconPathDto | undefined, inputBoxDocumentUri: UriComponents): Promise; $updateSourceControl(handle: number, features: SCMProviderFeatures): Promise; $unregisterSourceControl(handle: number): Promise; @@ -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; } diff --git a/src/vs/workbench/api/common/extHostQuickOpen.ts b/src/vs/workbench/api/common/extHostQuickOpen.ts index 05598c71d6a..8824dba941e 100644 --- a/src/vs/workbench/api/common/extHostQuickOpen.ts +++ b/src/vs/workbench/api/common/extHostQuickOpen.ts @@ -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((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 extends ExtHostQuickInput implements QuickPick { 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((button, i) => { return { - ...getIconPathOrClass(button.iconPath), + iconPathDto: IconPath.from(button.iconPath), tooltip: button.tooltip, handle: i }; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 6571d8f86d8..3edcbf7cb7d 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -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 { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index dec735ba8e0..a61088ba6ce 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -2544,7 +2544,8 @@ export class DebugVisualization { export enum QuickInputButtonLocation { Title = 1, - Inline = 2 + Inline = 2, + Input = 3 } @es5ClassCompat diff --git a/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts b/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts new file mode 100644 index 00000000000..ed0e1ceae16 --- /dev/null +++ b/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts @@ -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()); + }); + }); + }); +}); diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index 897d2dc41cb..b88f240aa31 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -1475,16 +1475,8 @@ class TreeRenderer extends Disposable implements ITreeRenderer