Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/actionWidget/browser/actionList.ts
5240 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
import * as dom from '../../../base/browser/dom.js';
6
import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js';
7
import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js';
8
import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js';
9
import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js';
10
import { IAction } from '../../../base/common/actions.js';
11
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
12
import { Codicon } from '../../../base/common/codicons.js';
13
import { ResolvedKeybinding } from '../../../base/common/keybindings.js';
14
import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
15
import { OS } from '../../../base/common/platform.js';
16
import { ThemeIcon } from '../../../base/common/themables.js';
17
import './actionWidget.css';
18
import { localize } from '../../../nls.js';
19
import { IContextViewService } from '../../contextview/browser/contextView.js';
20
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
21
import { defaultListStyles } from '../../theme/browser/defaultStyles.js';
22
import { asCssVariable } from '../../theme/common/colorRegistry.js';
23
import { ILayoutService } from '../../layout/browser/layoutService.js';
24
import { IHoverService } from '../../hover/browser/hover.js';
25
import { MarkdownString } from '../../../base/common/htmlContent.js';
26
import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js';
27
import { IHoverPositionOptions, IHoverWidget } from '../../../base/browser/ui/hover/hover.js';
28
29
export const acceptSelectedActionCommand = 'acceptSelectedCodeAction';
30
export const previewSelectedActionCommand = 'previewSelectedCodeAction';
31
32
export interface IActionListDelegate<T> {
33
onHide(didCancel?: boolean): void;
34
onSelect(action: T, preview?: boolean): void;
35
onHover?(action: T, cancellationToken: CancellationToken): Promise<{ canPreview: boolean } | void>;
36
onFocus?(action: T | undefined): void;
37
}
38
39
/**
40
* Optional hover configuration shown when focusing/hovering over an action list item.
41
*/
42
export interface IActionListItemHover {
43
/**
44
* Content to display in the hover.
45
*/
46
readonly content?: string;
47
48
readonly position?: IHoverPositionOptions;
49
}
50
51
export interface IActionListItem<T> {
52
readonly item?: T;
53
readonly kind: ActionListItemKind;
54
readonly group?: { kind?: unknown; icon?: ThemeIcon; title: string };
55
readonly disabled?: boolean;
56
readonly label?: string;
57
readonly description?: string;
58
/**
59
* Optional hover configuration shown when focusing/hovering over the item.
60
*/
61
readonly hover?: IActionListItemHover;
62
readonly keybinding?: ResolvedKeybinding;
63
canPreview?: boolean | undefined;
64
readonly hideIcon?: boolean;
65
readonly tooltip?: string;
66
/**
67
* Optional toolbar actions shown when the item is focused or hovered.
68
*/
69
readonly toolbarActions?: IAction[];
70
}
71
72
interface IActionMenuTemplateData {
73
readonly container: HTMLElement;
74
readonly icon: HTMLElement;
75
readonly text: HTMLElement;
76
readonly description?: HTMLElement;
77
readonly keybinding: KeybindingLabel;
78
readonly toolbar: HTMLElement;
79
readonly elementDisposables: DisposableStore;
80
}
81
82
export const enum ActionListItemKind {
83
Action = 'action',
84
Header = 'header',
85
Separator = 'separator'
86
}
87
88
interface IHeaderTemplateData {
89
readonly container: HTMLElement;
90
readonly text: HTMLElement;
91
}
92
93
class HeaderRenderer<T> implements IListRenderer<IActionListItem<T>, IHeaderTemplateData> {
94
95
get templateId(): string { return ActionListItemKind.Header; }
96
97
renderTemplate(container: HTMLElement): IHeaderTemplateData {
98
container.classList.add('group-header');
99
100
const text = document.createElement('span');
101
container.append(text);
102
103
return { container, text };
104
}
105
106
renderElement(element: IActionListItem<T>, _index: number, templateData: IHeaderTemplateData): void {
107
templateData.text.textContent = element.group?.title ?? element.label ?? '';
108
}
109
110
disposeTemplate(_templateData: IHeaderTemplateData): void {
111
// noop
112
}
113
}
114
115
interface ISeparatorTemplateData {
116
readonly container: HTMLElement;
117
readonly text: HTMLElement;
118
}
119
120
class SeparatorRenderer<T> implements IListRenderer<IActionListItem<T>, ISeparatorTemplateData> {
121
122
get templateId(): string { return ActionListItemKind.Separator; }
123
124
renderTemplate(container: HTMLElement): ISeparatorTemplateData {
125
container.classList.add('separator');
126
127
const text = document.createElement('span');
128
container.append(text);
129
130
return { container, text };
131
}
132
133
renderElement(element: IActionListItem<T>, _index: number, templateData: ISeparatorTemplateData): void {
134
templateData.text.textContent = element.label ?? '';
135
}
136
137
disposeTemplate(_templateData: ISeparatorTemplateData): void {
138
// noop
139
}
140
}
141
142
class ActionItemRenderer<T> implements IListRenderer<IActionListItem<T>, IActionMenuTemplateData> {
143
144
get templateId(): string { return ActionListItemKind.Action; }
145
146
constructor(
147
private readonly _supportsPreview: boolean,
148
@IKeybindingService private readonly _keybindingService: IKeybindingService
149
) { }
150
151
renderTemplate(container: HTMLElement): IActionMenuTemplateData {
152
container.classList.add(this.templateId);
153
154
const icon = document.createElement('div');
155
icon.className = 'icon';
156
container.append(icon);
157
158
const text = document.createElement('span');
159
text.className = 'title';
160
container.append(text);
161
162
const description = document.createElement('span');
163
description.className = 'description';
164
container.append(description);
165
166
const keybinding = new KeybindingLabel(container, OS);
167
168
const toolbar = document.createElement('div');
169
toolbar.className = 'action-list-item-toolbar';
170
container.append(toolbar);
171
172
const elementDisposables = new DisposableStore();
173
174
return { container, icon, text, description, keybinding, toolbar, elementDisposables };
175
}
176
177
renderElement(element: IActionListItem<T>, _index: number, data: IActionMenuTemplateData): void {
178
// Clear previous element disposables
179
data.elementDisposables.clear();
180
181
if (element.group?.icon) {
182
data.icon.className = ThemeIcon.asClassName(element.group.icon);
183
if (element.group.icon.color) {
184
data.icon.style.color = asCssVariable(element.group.icon.color.id);
185
}
186
} else {
187
data.icon.className = ThemeIcon.asClassName(Codicon.lightBulb);
188
data.icon.style.color = 'var(--vscode-editorLightBulb-foreground)';
189
}
190
191
if (!element.item || !element.label) {
192
return;
193
}
194
195
dom.setVisibility(!element.hideIcon, data.icon);
196
197
data.text.textContent = stripNewlines(element.label);
198
199
// if there is a keybinding, prioritize over description for now
200
if (element.keybinding) {
201
data.description!.textContent = element.keybinding.getLabel();
202
data.description!.style.display = 'inline';
203
data.description!.style.letterSpacing = '0.5px';
204
} else if (element.description) {
205
data.description!.textContent = stripNewlines(element.description);
206
data.description!.style.display = 'inline';
207
} else {
208
data.description!.textContent = '';
209
data.description!.style.display = 'none';
210
}
211
212
const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel();
213
const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel();
214
data.container.classList.toggle('option-disabled', element.disabled);
215
if (element.hover !== undefined) {
216
// Don't show tooltip when hover content is configured - the rich hover will show instead
217
data.container.title = '';
218
} else if (element.tooltip) {
219
data.container.title = element.tooltip;
220
} else if (element.disabled) {
221
data.container.title = element.label;
222
} else if (actionTitle && previewTitle) {
223
if (this._supportsPreview && element.canPreview) {
224
data.container.title = localize({ key: 'label-preview', comment: ['placeholders are keybindings, e.g "F2 to Apply, Shift+F2 to Preview"'] }, "{0} to Apply, {1} to Preview", actionTitle, previewTitle);
225
} else {
226
data.container.title = localize({ key: 'label', comment: ['placeholder is a keybinding, e.g "F2 to Apply"'] }, "{0} to Apply", actionTitle);
227
}
228
} else {
229
data.container.title = '';
230
}
231
232
// Clear and render toolbar actions
233
dom.clearNode(data.toolbar);
234
data.container.classList.toggle('has-toolbar', !!element.toolbarActions?.length);
235
if (element.toolbarActions?.length) {
236
const actionBar = new ActionBar(data.toolbar);
237
data.elementDisposables.add(actionBar);
238
actionBar.push(element.toolbarActions, { icon: true, label: false });
239
}
240
}
241
242
disposeTemplate(templateData: IActionMenuTemplateData): void {
243
templateData.keybinding.dispose();
244
templateData.elementDisposables.dispose();
245
}
246
}
247
248
class AcceptSelectedEvent extends UIEvent {
249
constructor() { super('acceptSelectedAction'); }
250
}
251
252
class PreviewSelectedEvent extends UIEvent {
253
constructor() { super('previewSelectedAction'); }
254
}
255
256
function getKeyboardNavigationLabel<T>(item: IActionListItem<T>): string | undefined {
257
// Filter out header vs. action vs. separator
258
if (item.kind === 'action') {
259
return item.label;
260
}
261
return undefined;
262
}
263
264
export class ActionList<T> extends Disposable {
265
266
public readonly domNode: HTMLElement;
267
268
private readonly _list: List<IActionListItem<T>>;
269
270
private readonly _actionLineHeight = 28;
271
private readonly _headerLineHeight = 28;
272
private readonly _separatorLineHeight = 8;
273
274
private readonly _allMenuItems: readonly IActionListItem<T>[];
275
276
private readonly cts = this._register(new CancellationTokenSource());
277
278
private _hover = this._register(new MutableDisposable<IHoverWidget>());
279
280
constructor(
281
user: string,
282
preview: boolean,
283
items: readonly IActionListItem<T>[],
284
private readonly _delegate: IActionListDelegate<T>,
285
accessibilityProvider: Partial<IListAccessibilityProvider<IActionListItem<T>>> | undefined,
286
@IContextViewService private readonly _contextViewService: IContextViewService,
287
@IKeybindingService private readonly _keybindingService: IKeybindingService,
288
@ILayoutService private readonly _layoutService: ILayoutService,
289
@IHoverService private readonly _hoverService: IHoverService,
290
) {
291
super();
292
this.domNode = document.createElement('div');
293
this.domNode.classList.add('actionList');
294
const virtualDelegate: IListVirtualDelegate<IActionListItem<T>> = {
295
getHeight: element => {
296
switch (element.kind) {
297
case ActionListItemKind.Header:
298
return this._headerLineHeight;
299
case ActionListItemKind.Separator:
300
return this._separatorLineHeight;
301
default:
302
return this._actionLineHeight;
303
}
304
},
305
getTemplateId: element => element.kind
306
};
307
308
309
this._list = this._register(new List(user, this.domNode, virtualDelegate, [
310
new ActionItemRenderer<IActionListItem<T>>(preview, this._keybindingService),
311
new HeaderRenderer(),
312
new SeparatorRenderer(),
313
], {
314
keyboardSupport: false,
315
typeNavigationEnabled: true,
316
keyboardNavigationLabelProvider: { getKeyboardNavigationLabel },
317
accessibilityProvider: {
318
getAriaLabel: element => {
319
if (element.kind === ActionListItemKind.Action) {
320
let label = element.label ? stripNewlines(element?.label) : '';
321
if (element.description) {
322
label = label + ', ' + stripNewlines(element.description);
323
}
324
if (element.disabled) {
325
label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled);
326
}
327
return label;
328
}
329
return null;
330
},
331
getWidgetAriaLabel: () => localize({ key: 'customQuickFixWidget', comment: [`An action widget option`] }, "Action Widget"),
332
getRole: (e) => {
333
switch (e.kind) {
334
case ActionListItemKind.Action:
335
return 'option';
336
case ActionListItemKind.Separator:
337
return 'separator';
338
default:
339
return 'separator';
340
}
341
},
342
getWidgetRole: () => 'listbox',
343
...accessibilityProvider
344
},
345
}));
346
347
this._list.style(defaultListStyles);
348
349
this._register(this._list.onMouseClick(e => this.onListClick(e)));
350
this._register(this._list.onMouseOver(e => this.onListHover(e)));
351
this._register(this._list.onDidChangeFocus(() => this.onFocus()));
352
this._register(this._list.onDidChangeSelection(e => this.onListSelection(e)));
353
354
this._allMenuItems = items;
355
this._list.splice(0, this._list.length, this._allMenuItems);
356
357
if (this._list.length) {
358
this.focusNext();
359
}
360
}
361
362
private focusCondition(element: IActionListItem<unknown>): boolean {
363
return !element.disabled && element.kind === ActionListItemKind.Action;
364
}
365
366
hide(didCancel?: boolean): void {
367
this._delegate.onHide(didCancel);
368
this.cts.cancel();
369
this._hover.clear();
370
this._contextViewService.hideContextView();
371
}
372
373
layout(minWidth: number): number {
374
// Updating list height, depending on how many separators and headers there are.
375
const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length;
376
const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length;
377
const itemsHeight = this._allMenuItems.length * this._actionLineHeight;
378
const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight;
379
const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight;
380
this._list.layout(heightWithSeparators);
381
let maxWidth = minWidth;
382
383
if (this._allMenuItems.length >= 50) {
384
maxWidth = 380;
385
} else {
386
// For finding width dynamically (not using resize observer)
387
const itemWidths: number[] = this._allMenuItems.map((_, index): number => {
388
const element = this._getRowElement(index);
389
if (element) {
390
element.style.width = 'auto';
391
const width = element.getBoundingClientRect().width;
392
element.style.width = '';
393
return width;
394
}
395
return 0;
396
});
397
398
// resize observer - can be used in the future since list widget supports dynamic height but not width
399
maxWidth = Math.max(...itemWidths, minWidth);
400
}
401
402
const maxVhPrecentage = 0.7;
403
const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage);
404
this._list.layout(height, maxWidth);
405
406
this.domNode.style.height = `${height}px`;
407
408
this._list.domFocus();
409
return maxWidth;
410
}
411
412
focusPrevious() {
413
this._list.focusPrevious(1, true, undefined, this.focusCondition);
414
}
415
416
focusNext() {
417
this._list.focusNext(1, true, undefined, this.focusCondition);
418
}
419
420
acceptSelected(preview?: boolean) {
421
const focused = this._list.getFocus();
422
if (focused.length === 0) {
423
return;
424
}
425
426
const focusIndex = focused[0];
427
const element = this._list.element(focusIndex);
428
if (!this.focusCondition(element)) {
429
return;
430
}
431
432
const event = preview ? new PreviewSelectedEvent() : new AcceptSelectedEvent();
433
this._list.setSelection([focusIndex], event);
434
}
435
436
private onListSelection(e: IListEvent<IActionListItem<T>>): void {
437
if (!e.elements.length) {
438
return;
439
}
440
441
const element = e.elements[0];
442
if (element.item && this.focusCondition(element)) {
443
this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent);
444
} else {
445
this._list.setSelection([]);
446
}
447
}
448
449
private onFocus() {
450
const focused = this._list.getFocus();
451
if (focused.length === 0) {
452
return;
453
}
454
const focusIndex = focused[0];
455
const element = this._list.element(focusIndex);
456
this._delegate.onFocus?.(element.item);
457
458
// Show hover on focus change
459
this._showHoverForElement(element, focusIndex);
460
}
461
462
private _getRowElement(index: number): HTMLElement | null {
463
// eslint-disable-next-line no-restricted-syntax
464
return this.domNode.ownerDocument.getElementById(this._list.getElementID(index));
465
}
466
467
private _showHoverForElement(element: IActionListItem<T>, index: number): void {
468
let newHover: IHoverWidget | undefined;
469
470
// Show hover if the element has hover content
471
if (element.hover?.content && this.focusCondition(element)) {
472
// The List widget separates data models from DOM elements, so we need to
473
// look up the actual DOM node to use as the hover target.
474
const rowElement = this._getRowElement(index);
475
if (rowElement) {
476
const markdown = element.hover.content ? new MarkdownString(element.hover.content) : undefined;
477
newHover = this._hoverService.showDelayedHover({
478
content: markdown ?? '',
479
target: rowElement,
480
additionalClasses: ['action-widget-hover'],
481
position: {
482
hoverPosition: HoverPosition.LEFT,
483
forcePosition: false,
484
...element.hover.position,
485
},
486
appearance: {
487
showPointer: true,
488
},
489
}, { groupId: `actionListHover` });
490
}
491
}
492
493
this._hover.value = newHover;
494
}
495
496
private async onListHover(e: IListMouseEvent<IActionListItem<T>>) {
497
const element = e.element;
498
499
if (element && element.item && this.focusCondition(element)) {
500
// Check if the hover target is inside a toolbar - if so, skip the splice
501
// to avoid re-rendering which would destroy the toolbar mid-hover
502
const isHoveringToolbar = dom.isHTMLElement(e.browserEvent.target) && e.browserEvent.target.closest('.action-list-item-toolbar') !== null;
503
if (isHoveringToolbar) {
504
this._list.setFocus([]);
505
return;
506
}
507
508
if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) {
509
const result = await this._delegate.onHover(element.item, this.cts.token);
510
element.canPreview = result ? result.canPreview : undefined;
511
}
512
if (e.index) {
513
this._list.splice(e.index, 1, [element]);
514
}
515
516
this._list.setFocus(typeof e.index === 'number' ? [e.index] : []);
517
}
518
}
519
520
private onListClick(e: IListMouseEvent<IActionListItem<T>>): void {
521
if (e.element && this.focusCondition(e.element)) {
522
this._list.setFocus([]);
523
}
524
}
525
}
526
527
function stripNewlines(str: string): string {
528
return str.replace(/\r\n|\r|\n/g, ' ');
529
}
530
531