Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/actionWidget/browser/actionList.ts
3296 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 { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js';
7
import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js';
8
import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js';
9
import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js';
10
import { Codicon } from '../../../base/common/codicons.js';
11
import { ResolvedKeybinding } from '../../../base/common/keybindings.js';
12
import { Disposable } from '../../../base/common/lifecycle.js';
13
import { OS } from '../../../base/common/platform.js';
14
import { ThemeIcon } from '../../../base/common/themables.js';
15
import './actionWidget.css';
16
import { localize } from '../../../nls.js';
17
import { IContextViewService } from '../../contextview/browser/contextView.js';
18
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
19
import { defaultListStyles } from '../../theme/browser/defaultStyles.js';
20
import { asCssVariable } from '../../theme/common/colorRegistry.js';
21
import { ILayoutService } from '../../layout/browser/layoutService.js';
22
23
export const acceptSelectedActionCommand = 'acceptSelectedCodeAction';
24
export const previewSelectedActionCommand = 'previewSelectedCodeAction';
25
26
export interface IActionListDelegate<T> {
27
onHide(didCancel?: boolean): void;
28
onSelect(action: T, preview?: boolean): void;
29
onHover?(action: T, cancellationToken: CancellationToken): Promise<{ canPreview: boolean } | void>;
30
onFocus?(action: T | undefined): void;
31
}
32
33
export interface IActionListItem<T> {
34
readonly item?: T;
35
readonly kind: ActionListItemKind;
36
readonly group?: { kind?: any; icon?: ThemeIcon; title: string };
37
readonly disabled?: boolean;
38
readonly label?: string;
39
readonly description?: string;
40
readonly keybinding?: ResolvedKeybinding;
41
canPreview?: boolean | undefined;
42
readonly hideIcon?: boolean;
43
readonly tooltip?: string;
44
}
45
46
interface IActionMenuTemplateData {
47
readonly container: HTMLElement;
48
readonly icon: HTMLElement;
49
readonly text: HTMLElement;
50
readonly description?: HTMLElement;
51
readonly keybinding: KeybindingLabel;
52
}
53
54
export const enum ActionListItemKind {
55
Action = 'action',
56
Header = 'header',
57
Separator = 'separator'
58
}
59
60
interface IHeaderTemplateData {
61
readonly container: HTMLElement;
62
readonly text: HTMLElement;
63
}
64
65
class HeaderRenderer<T> implements IListRenderer<IActionListItem<T>, IHeaderTemplateData> {
66
67
get templateId(): string { return ActionListItemKind.Header; }
68
69
renderTemplate(container: HTMLElement): IHeaderTemplateData {
70
container.classList.add('group-header');
71
72
const text = document.createElement('span');
73
container.append(text);
74
75
return { container, text };
76
}
77
78
renderElement(element: IActionListItem<T>, _index: number, templateData: IHeaderTemplateData): void {
79
templateData.text.textContent = element.group?.title ?? element.label ?? '';
80
}
81
82
disposeTemplate(_templateData: IHeaderTemplateData): void {
83
// noop
84
}
85
}
86
87
interface ISeparatorTemplateData {
88
readonly container: HTMLElement;
89
readonly text: HTMLElement;
90
}
91
92
class SeparatorRenderer<T> implements IListRenderer<IActionListItem<T>, ISeparatorTemplateData> {
93
94
get templateId(): string { return ActionListItemKind.Separator; }
95
96
renderTemplate(container: HTMLElement): ISeparatorTemplateData {
97
container.classList.add('separator');
98
99
const text = document.createElement('span');
100
container.append(text);
101
102
return { container, text };
103
}
104
105
renderElement(element: IActionListItem<T>, _index: number, templateData: ISeparatorTemplateData): void {
106
templateData.text.textContent = element.label ?? '';
107
}
108
109
disposeTemplate(_templateData: ISeparatorTemplateData): void {
110
// noop
111
}
112
}
113
114
class ActionItemRenderer<T> implements IListRenderer<IActionListItem<T>, IActionMenuTemplateData> {
115
116
get templateId(): string { return ActionListItemKind.Action; }
117
118
constructor(
119
private readonly _supportsPreview: boolean,
120
@IKeybindingService private readonly _keybindingService: IKeybindingService
121
) { }
122
123
renderTemplate(container: HTMLElement): IActionMenuTemplateData {
124
container.classList.add(this.templateId);
125
126
const icon = document.createElement('div');
127
icon.className = 'icon';
128
container.append(icon);
129
130
const text = document.createElement('span');
131
text.className = 'title';
132
container.append(text);
133
134
const description = document.createElement('span');
135
description.className = 'description';
136
container.append(description);
137
138
const keybinding = new KeybindingLabel(container, OS);
139
140
return { container, icon, text, description, keybinding };
141
}
142
143
renderElement(element: IActionListItem<T>, _index: number, data: IActionMenuTemplateData): void {
144
if (element.group?.icon) {
145
data.icon.className = ThemeIcon.asClassName(element.group.icon);
146
if (element.group.icon.color) {
147
data.icon.style.color = asCssVariable(element.group.icon.color.id);
148
}
149
} else {
150
data.icon.className = ThemeIcon.asClassName(Codicon.lightBulb);
151
data.icon.style.color = 'var(--vscode-editorLightBulb-foreground)';
152
}
153
154
if (!element.item || !element.label) {
155
return;
156
}
157
158
dom.setVisibility(!element.hideIcon, data.icon);
159
160
data.text.textContent = stripNewlines(element.label);
161
162
// if there is a keybinding, prioritize over description for now
163
if (element.keybinding) {
164
data.description!.textContent = element.keybinding.getLabel();
165
data.description!.style.display = 'inline';
166
data.description!.style.letterSpacing = '0.5px';
167
} else if (element.description) {
168
data.description!.textContent = stripNewlines(element.description);
169
data.description!.style.display = 'inline';
170
} else {
171
data.description!.textContent = '';
172
data.description!.style.display = 'none';
173
}
174
175
const actionTitle = this._keybindingService.lookupKeybinding(acceptSelectedActionCommand)?.getLabel();
176
const previewTitle = this._keybindingService.lookupKeybinding(previewSelectedActionCommand)?.getLabel();
177
data.container.classList.toggle('option-disabled', element.disabled);
178
if (element.tooltip) {
179
data.container.title = element.tooltip;
180
} else if (element.disabled) {
181
data.container.title = element.label;
182
} else if (actionTitle && previewTitle) {
183
if (this._supportsPreview && element.canPreview) {
184
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);
185
} else {
186
data.container.title = localize({ key: 'label', comment: ['placeholder is a keybinding, e.g "F2 to Apply"'] }, "{0} to Apply", actionTitle);
187
}
188
} else {
189
data.container.title = '';
190
}
191
}
192
193
disposeTemplate(templateData: IActionMenuTemplateData): void {
194
templateData.keybinding.dispose();
195
}
196
}
197
198
class AcceptSelectedEvent extends UIEvent {
199
constructor() { super('acceptSelectedAction'); }
200
}
201
202
class PreviewSelectedEvent extends UIEvent {
203
constructor() { super('previewSelectedAction'); }
204
}
205
206
function getKeyboardNavigationLabel<T>(item: IActionListItem<T>): string | undefined {
207
// Filter out header vs. action vs. separator
208
if (item.kind === 'action') {
209
return item.label;
210
}
211
return undefined;
212
}
213
214
export class ActionList<T> extends Disposable {
215
216
public readonly domNode: HTMLElement;
217
218
private readonly _list: List<IActionListItem<T>>;
219
220
private readonly _actionLineHeight = 24;
221
private readonly _headerLineHeight = 26;
222
private readonly _separatorLineHeight = 8;
223
224
private readonly _allMenuItems: readonly IActionListItem<T>[];
225
226
private readonly cts = this._register(new CancellationTokenSource());
227
228
constructor(
229
user: string,
230
preview: boolean,
231
items: readonly IActionListItem<T>[],
232
private readonly _delegate: IActionListDelegate<T>,
233
accessibilityProvider: Partial<IListAccessibilityProvider<IActionListItem<T>>> | undefined,
234
@IContextViewService private readonly _contextViewService: IContextViewService,
235
@IKeybindingService private readonly _keybindingService: IKeybindingService,
236
@ILayoutService private readonly _layoutService: ILayoutService,
237
) {
238
super();
239
this.domNode = document.createElement('div');
240
this.domNode.classList.add('actionList');
241
const virtualDelegate: IListVirtualDelegate<IActionListItem<T>> = {
242
getHeight: element => {
243
switch (element.kind) {
244
case ActionListItemKind.Header:
245
return this._headerLineHeight;
246
case ActionListItemKind.Separator:
247
return this._separatorLineHeight;
248
default:
249
return this._actionLineHeight;
250
}
251
},
252
getTemplateId: element => element.kind
253
};
254
255
256
this._list = this._register(new List(user, this.domNode, virtualDelegate, [
257
new ActionItemRenderer<IActionListItem<T>>(preview, this._keybindingService),
258
new HeaderRenderer(),
259
new SeparatorRenderer(),
260
], {
261
keyboardSupport: false,
262
typeNavigationEnabled: true,
263
keyboardNavigationLabelProvider: { getKeyboardNavigationLabel },
264
accessibilityProvider: {
265
getAriaLabel: element => {
266
if (element.kind === ActionListItemKind.Action) {
267
let label = element.label ? stripNewlines(element?.label) : '';
268
if (element.disabled) {
269
label = localize({ key: 'customQuickFixWidget.labels', comment: [`Action widget labels for accessibility.`] }, "{0}, Disabled Reason: {1}", label, element.disabled);
270
}
271
return label;
272
}
273
return null;
274
},
275
getWidgetAriaLabel: () => localize({ key: 'customQuickFixWidget', comment: [`An action widget option`] }, "Action Widget"),
276
getRole: (e) => {
277
switch (e.kind) {
278
case ActionListItemKind.Action:
279
return 'option';
280
case ActionListItemKind.Separator:
281
return 'separator';
282
default:
283
return 'separator';
284
}
285
},
286
getWidgetRole: () => 'listbox',
287
...accessibilityProvider
288
},
289
}));
290
291
this._list.style(defaultListStyles);
292
293
this._register(this._list.onMouseClick(e => this.onListClick(e)));
294
this._register(this._list.onMouseOver(e => this.onListHover(e)));
295
this._register(this._list.onDidChangeFocus(() => this.onFocus()));
296
this._register(this._list.onDidChangeSelection(e => this.onListSelection(e)));
297
298
this._allMenuItems = items;
299
this._list.splice(0, this._list.length, this._allMenuItems);
300
301
if (this._list.length) {
302
this.focusNext();
303
}
304
}
305
306
private focusCondition(element: IActionListItem<unknown>): boolean {
307
return !element.disabled && element.kind === ActionListItemKind.Action;
308
}
309
310
hide(didCancel?: boolean): void {
311
this._delegate.onHide(didCancel);
312
this.cts.cancel();
313
this._contextViewService.hideContextView();
314
}
315
316
layout(minWidth: number): number {
317
// Updating list height, depending on how many separators and headers there are.
318
const numHeaders = this._allMenuItems.filter(item => item.kind === 'header').length;
319
const numSeparators = this._allMenuItems.filter(item => item.kind === 'separator').length;
320
const itemsHeight = this._allMenuItems.length * this._actionLineHeight;
321
const heightWithHeaders = itemsHeight + numHeaders * this._headerLineHeight - numHeaders * this._actionLineHeight;
322
const heightWithSeparators = heightWithHeaders + numSeparators * this._separatorLineHeight - numSeparators * this._actionLineHeight;
323
this._list.layout(heightWithSeparators);
324
let maxWidth = minWidth;
325
326
if (this._allMenuItems.length >= 50) {
327
maxWidth = 380;
328
} else {
329
// For finding width dynamically (not using resize observer)
330
const itemWidths: number[] = this._allMenuItems.map((_, index): number => {
331
const element = this.domNode.ownerDocument.getElementById(this._list.getElementID(index));
332
if (element) {
333
element.style.width = 'auto';
334
const width = element.getBoundingClientRect().width;
335
element.style.width = '';
336
return width;
337
}
338
return 0;
339
});
340
341
// resize observer - can be used in the future since list widget supports dynamic height but not width
342
maxWidth = Math.max(...itemWidths, minWidth);
343
}
344
345
const maxVhPrecentage = 0.7;
346
const height = Math.min(heightWithSeparators, this._layoutService.getContainer(dom.getWindow(this.domNode)).clientHeight * maxVhPrecentage);
347
this._list.layout(height, maxWidth);
348
349
this.domNode.style.height = `${height}px`;
350
351
this._list.domFocus();
352
return maxWidth;
353
}
354
355
focusPrevious() {
356
this._list.focusPrevious(1, true, undefined, this.focusCondition);
357
}
358
359
focusNext() {
360
this._list.focusNext(1, true, undefined, this.focusCondition);
361
}
362
363
acceptSelected(preview?: boolean) {
364
const focused = this._list.getFocus();
365
if (focused.length === 0) {
366
return;
367
}
368
369
const focusIndex = focused[0];
370
const element = this._list.element(focusIndex);
371
if (!this.focusCondition(element)) {
372
return;
373
}
374
375
const event = preview ? new PreviewSelectedEvent() : new AcceptSelectedEvent();
376
this._list.setSelection([focusIndex], event);
377
}
378
379
private onListSelection(e: IListEvent<IActionListItem<T>>): void {
380
if (!e.elements.length) {
381
return;
382
}
383
384
const element = e.elements[0];
385
if (element.item && this.focusCondition(element)) {
386
this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent);
387
} else {
388
this._list.setSelection([]);
389
}
390
}
391
392
private onFocus() {
393
const focused = this._list.getFocus();
394
if (focused.length === 0) {
395
return;
396
}
397
const focusIndex = focused[0];
398
const element = this._list.element(focusIndex);
399
this._delegate.onFocus?.(element.item);
400
}
401
402
private async onListHover(e: IListMouseEvent<IActionListItem<T>>) {
403
const element = e.element;
404
if (element && element.item && this.focusCondition(element)) {
405
if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) {
406
const result = await this._delegate.onHover(element.item, this.cts.token);
407
element.canPreview = result ? result.canPreview : undefined;
408
}
409
if (e.index) {
410
this._list.splice(e.index, 1, [element]);
411
}
412
}
413
414
this._list.setFocus(typeof e.index === 'number' ? [e.index] : []);
415
}
416
417
private onListClick(e: IListMouseEvent<IActionListItem<T>>): void {
418
if (e.element && this.focusCondition(e.element)) {
419
this._list.setFocus([]);
420
}
421
}
422
}
423
424
function stripNewlines(str: string): string {
425
return str.replace(/\r\n|\r|\n/g, ' ');
426
}
427
428