Skip to content

Commit

Permalink
Support links in InputBox prompts and validations (#173885)
Browse files Browse the repository at this point in the history
Fixes #82112
  • Loading branch information
TylerLeonhardt authored Feb 8, 2023
1 parent f3901eb commit 906d849
Show file tree
Hide file tree
Showing 5 changed files with 81 additions and 3 deletions.
6 changes: 6 additions & 0 deletions src/vs/platform/quickinput/browser/media/quickInput.css
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@
vertical-align: text-bottom;
}

/* Links in descriptions & validations */
.quick-input-message a {
text-decoration: underline;
color: inherit;
}

.quick-input-progress.monaco-progress-container {
position: relative;
}
Expand Down
18 changes: 15 additions & 3 deletions src/vs/platform/quickinput/browser/quickInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar';
import { ActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems';
import { Button, IButtonStyles } from 'vs/base/browser/ui/button/button';
import { CountBadge, ICountBadgeStyles } from 'vs/base/browser/ui/countBadge/countBadge';
import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels';
import { IInputBoxStyles } from 'vs/base/browser/ui/inputbox/inputBox';
import { IKeybindingLabelStyles } from 'vs/base/browser/ui/keybindingLabel/keybindingLabel';
import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
Expand All @@ -34,7 +33,7 @@ import { ILayoutService } from 'vs/platform/layout/browser/layoutService';
import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickInputHideEvent, IQuickInputToggle, IQuickNavigateConfiguration, IQuickPick, IQuickPickDidAcceptEvent, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator, IQuickPickSeparatorButtonEvent, IQuickPickWillAcceptEvent, ItemActivation, NO_KEY_MODS, QuickInputHideReason, QuickPickInput } from 'vs/platform/quickinput/common/quickInput';
import { QuickInputBox } from './quickInputBox';
import { QuickInputList, QuickInputListFocus } from './quickInputList';
import { getIconClass } from './quickInputUtils';
import { getIconClass, renderQuickInputDescription } from './quickInputUtils';

export interface IQuickInputOptions {
idPrefix: string;
Expand All @@ -43,6 +42,7 @@ export interface IQuickInputOptions {
isScreenReaderOptimized(): boolean;
backKeybindingLabel(): string | undefined;
setContextKey(id?: string): void;
linkOpenerDelegate(content: string): void;
returnFocus(): void;
createList<T>(
user: string,
Expand Down Expand Up @@ -118,6 +118,7 @@ interface QuickInputUI {
setComboboxAccessibility(enabled: boolean): void;
setEnabled(enabled: boolean): void;
setContextKey(contextKey?: string): void;
linkOpenerDelegate(content: string): void;
hide(): void;
}

Expand Down Expand Up @@ -400,7 +401,13 @@ class QuickInput extends Disposable implements IQuickInput {
const validationMessage = this.validationMessage || this.noValidationMessage;
if (this._lastValidationMessage !== validationMessage) {
this._lastValidationMessage = validationMessage;
dom.reset(this.ui.message, ...renderLabelWithIcons(validationMessage));
dom.reset(this.ui.message);
renderQuickInputDescription(validationMessage, this.ui.message, {
callback: (content) => {
this.ui.linkOpenerDelegate(content);
},
disposables: this.visibleDisposables,
});
}
if (this._lastSeverity !== this.severity) {
this._lastSeverity = this.severity;
Expand Down Expand Up @@ -1395,6 +1402,10 @@ export class QuickInputController extends Disposable {
if (this.getUI().list.isDisplayed()) {
selectors.push('.monaco-list');
}
// focus links if there are any
if (this.getUI().message) {
selectors.push('.quick-input-message a');
}
const stops = container.querySelectorAll<HTMLElement>(selectors.join(', '));
if (event.shiftKey && event.target === stops[0]) {
dom.EventHelper.stop(e, true);
Expand Down Expand Up @@ -1443,6 +1454,7 @@ export class QuickInputController extends Disposable {
setComboboxAccessibility: enabled => this.setComboboxAccessibility(enabled),
setEnabled: enabled => this.setEnabled(enabled),
setContextKey: contextKey => this.options.setContextKey(contextKey),
linkOpenerDelegate: content => this.options.linkOpenerDelegate(content)
};
this.updateStyles();
return this.ui;
Expand Down
8 changes: 8 additions & 0 deletions src/vs/platform/quickinput/browser/quickInputService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/cont
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ILayoutService } from 'vs/platform/layout/browser/layoutService';
import { IWorkbenchListOptions, WorkbenchList } from 'vs/platform/list/browser/listService';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { QuickAccessController } from 'vs/platform/quickinput/browser/quickAccess';
import { IQuickAccessController } from 'vs/platform/quickinput/common/quickAccess';
import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, QuickPickInput } from 'vs/platform/quickinput/common/quickInput';
Expand Down Expand Up @@ -73,6 +74,13 @@ export class QuickInputService extends Themable implements IQuickInputService {
isScreenReaderOptimized: () => this.accessibilityService.isScreenReaderOptimized(),
backKeybindingLabel: () => undefined,
setContextKey: (id?: string) => this.setContextKey(id),
linkOpenerDelegate: (content) => {
// HACK: https://github.com/microsoft/vscode/issues/173691
this.instantiationService.invokeFunction(accessor => {
const openerService = accessor.get(IOpenerService);
openerService.open(content, { allowCommands: true, fromUserGesture: true });
});
},
returnFocus: () => host.focus(),
createList: <T>(
user: string,
Expand Down
51 changes: 51 additions & 0 deletions src/vs/platform/quickinput/browser/quickInputUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@
*--------------------------------------------------------------------------------------------*/

import * as dom from 'vs/base/browser/dom';
import { DomEmitter } from 'vs/base/browser/event';
import { Event } from 'vs/base/common/event';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { Gesture, EventType as GestureEventType } from 'vs/base/browser/touch';
import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels';
import { IdGenerator } from 'vs/base/common/idGenerator';
import { KeyCode } from 'vs/base/common/keyCodes';
import { parseLinkedText } from 'vs/base/common/linkedText';
import { URI } from 'vs/base/common/uri';
import 'vs/css!./media/quickInput';
import { localize } from 'vs/nls';
import { DisposableStore } from 'vs/base/common/lifecycle';

const iconPathToClass: Record<string, string> = {};
const iconClassGenerator = new IdGenerator('quick-input-button-icon-');
Expand All @@ -29,3 +38,45 @@ export function getIconClass(iconPath: { dark: URI; light?: URI } | undefined):

return iconClass;
}

export function renderQuickInputDescription(description: string, container: HTMLElement, actionHandler: { callback: (content: string) => void; disposables: DisposableStore }) {
dom.reset(container);
const parsed = parseLinkedText(description);
let tabIndex = 0;
for (const node of parsed.nodes) {
if (typeof node === 'string') {
container.append(...renderLabelWithIcons(node));
} else {
let title = node.title;

if (!title && node.href.startsWith('command:')) {
title = localize('executeCommand', "Click to execute command '{0}'", node.href.substring('command:'.length));
} else if (!title) {
title = node.href;
}

const anchor = dom.$('a', { href: node.href, title, tabIndex: tabIndex++ }, node.label);
const handleOpen = (e: unknown) => {
if (dom.isEventLike(e)) {
dom.EventHelper.stop(e, true);
}

actionHandler.callback(node.href);
};

const onClick = actionHandler.disposables.add(new DomEmitter(anchor, dom.EventType.CLICK)).event;
const onKeydown = actionHandler.disposables.add(new DomEmitter(anchor, dom.EventType.KEY_DOWN)).event;
const onSpaceOrEnter = actionHandler.disposables.add(Event.chain(onKeydown)).filter(e => {
const event = new StandardKeyboardEvent(e);

return event.equals(KeyCode.Space) || event.equals(KeyCode.Enter);
}).event;

actionHandler.disposables.add(Gesture.addTarget(anchor));
const onTap = actionHandler.disposables.add(new DomEmitter(anchor, GestureEventType.Tap)).event;

Event.any(onClick, onTap, onSpaceOrEnter)(handleOpen, null, actionHandler.disposables);
container.appendChild(anchor);
}
}
}
1 change: 1 addition & 0 deletions src/vs/platform/quickinput/test/browser/quickinput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543
returnFocus() { },
backKeybindingLabel() { return undefined; },
setContextKey() { return undefined; },
linkOpenerDelegate(content) { },
createList: <T>(
user: string,
container: HTMLElement,
Expand Down

0 comments on commit 906d849

Please sign in to comment.