Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/browser/parts/projectBarPart.ts
13395 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
6
import './media/projectBarPart.css';
7
import { Part } from '../../../workbench/browser/part.js';
8
import { IWorkbenchLayoutService, Position } from '../../../workbench/services/layout/browser/layoutService.js';
9
import { IColorTheme, IThemeService } from '../../../platform/theme/common/themeService.js';
10
import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js';
11
import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js';
12
import { IHoverService } from '../../../platform/hover/browser/hover.js';
13
import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';
14
import { $, addDisposableListener, append, clearNode, Dimension, EventType, getActiveDocument, getWindow } from '../../../base/browser/dom.js';
15
import { Emitter, Event } from '../../../base/common/event.js';
16
import { ACTIVITY_BAR_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_BORDER, ACTIVITY_BAR_FOREGROUND, ACTIVITY_BAR_INACTIVE_FOREGROUND } from '../../../workbench/common/theme.js';
17
import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js';
18
import { assertReturnsDefined } from '../../../base/common/types.js';
19
import { ThemeIcon } from '../../../base/common/themables.js';
20
import { Codicon } from '../../../base/common/codicons.js';
21
import { codiconsLibrary } from '../../../base/common/codiconsLibrary.js';
22
import { Lazy } from '../../../base/common/lazy.js';
23
import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js';
24
import { GlobalCompositeBar } from '../../../workbench/browser/parts/globalCompositeBar.js';
25
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
26
import { IAction, Action, Separator } from '../../../base/common/actions.js';
27
import { URI } from '../../../base/common/uri.js';
28
import { IFileDialogService } from '../../../platform/dialogs/common/dialogs.js';
29
import { IPathService } from '../../../workbench/services/path/common/pathService.js';
30
import { IWorkspaceEditingService } from '../../../workbench/services/workspaces/common/workspaceEditing.js';
31
import { ILabelService } from '../../../platform/label/common/label.js';
32
import { basename } from '../../../base/common/resources.js';
33
import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js';
34
import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';
35
import { IQuickInputService, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js';
36
import { getIconRegistry, IconContribution } from '../../../platform/theme/common/iconRegistry.js';
37
import { defaultInputBoxStyles } from '../../../platform/theme/browser/defaultStyles.js';
38
import { WorkbenchIconSelectBox } from '../../../workbench/services/userDataProfile/browser/iconSelectBox.js';
39
import { localize } from '../../../nls.js';
40
import { AgenticParts } from './parts.js';
41
42
const HOVER_GROUP_ID = 'projectbar';
43
const PROJECT_BAR_FOLDERS_KEY = 'workbench.agentsession.projectbar.folders';
44
45
type ProjectBarEntryDisplayType = 'letter' | 'icon';
46
47
interface IProjectBarEntryData {
48
readonly uri: string;
49
readonly displayType?: ProjectBarEntryDisplayType;
50
readonly iconId?: string;
51
}
52
53
interface IProjectBarEntry {
54
readonly uri: URI;
55
readonly name: string;
56
displayType: ProjectBarEntryDisplayType;
57
iconId?: string;
58
}
59
60
const icons = new Lazy<IconContribution[]>(() => {
61
const iconDefinitions = getIconRegistry().getIcons();
62
const includedChars = new Set<string>();
63
const dedupedIcons = iconDefinitions.filter(e => {
64
if (e.id === codiconsLibrary.blank.id) {
65
return false;
66
}
67
if (ThemeIcon.isThemeIcon(e.defaults)) {
68
return false;
69
}
70
if (includedChars.has(e.defaults.fontCharacter)) {
71
return false;
72
}
73
includedChars.add(e.defaults.fontCharacter);
74
return true;
75
});
76
return dedupedIcons;
77
});
78
79
/**
80
* ProjectBarPart displays project folder entries stored in workspace storage and allows selection between them.
81
* When a folder is selected, the workspace editing service is used to replace the current workspace folder
82
* with the selected one. It is positioned to the left of the sidebar and has the same visual style as the activity bar.
83
* Also includes global activities (accounts, settings) at the bottom.
84
*/
85
export class ProjectBarPart extends Part {
86
87
static readonly ACTION_HEIGHT = 48;
88
89
//#region IView
90
91
readonly minimumWidth: number = 48;
92
readonly maximumWidth: number = 48;
93
readonly minimumHeight: number = 0;
94
readonly maximumHeight: number = Number.POSITIVE_INFINITY;
95
96
//#endregion
97
98
private content: HTMLElement | undefined;
99
private actionsContainer: HTMLElement | undefined;
100
private addFolderButton: HTMLElement | undefined;
101
private entries: IProjectBarEntry[] = [];
102
private _selectedFolderUri: URI | undefined;
103
private readonly globalCompositeBar: GlobalCompositeBar;
104
105
private readonly workspaceEntryDisposables = this._register(new MutableDisposable<DisposableStore>());
106
107
private readonly _onDidSelectWorkspace = this._register(new Emitter<URI | undefined>());
108
readonly onDidSelectWorkspace: Event<URI | undefined> = this._onDidSelectWorkspace.event;
109
110
constructor(
111
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
112
@IThemeService themeService: IThemeService,
113
@IStorageService private readonly storageService: IStorageService,
114
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
115
@IFileDialogService private readonly fileDialogService: IFileDialogService,
116
@IPathService private readonly pathService: IPathService,
117
@IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService,
118
@ILabelService private readonly labelService: ILabelService,
119
@IHoverService private readonly hoverService: IHoverService,
120
@IContextMenuService private readonly contextMenuService: IContextMenuService,
121
@IQuickInputService private readonly quickInputService: IQuickInputService,
122
@IInstantiationService private readonly instantiationService: IInstantiationService,
123
) {
124
super(AgenticParts.PROJECTBAR_PART, { hasTitle: false }, themeService, storageService, layoutService);
125
126
// Create the global composite bar for accounts and settings at the bottom
127
this.globalCompositeBar = this._register(instantiationService.createInstance(
128
GlobalCompositeBar,
129
() => this.getContextMenuActions(),
130
(theme: IColorTheme) => ({
131
activeForegroundColor: theme.getColor(ACTIVITY_BAR_FOREGROUND),
132
inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_INACTIVE_FOREGROUND),
133
badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND),
134
badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND),
135
activeBackgroundColor: undefined,
136
inactiveBackgroundColor: undefined,
137
activeBorderBottomColor: undefined,
138
}),
139
{
140
position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT,
141
}
142
));
143
144
// Load entries from storage
145
this.loadEntriesFromStorage();
146
}
147
148
private getContextMenuActions(): IAction[] {
149
return this.globalCompositeBar.getContextMenuActions();
150
}
151
152
private loadEntriesFromStorage(): void {
153
const raw = this.storageService.get(PROJECT_BAR_FOLDERS_KEY, StorageScope.WORKSPACE);
154
if (raw) {
155
try {
156
const data: (string | IProjectBarEntryData)[] = JSON.parse(raw);
157
this.entries = data.map(item => {
158
// Support legacy format (just URIs as strings) and new format (objects with display settings)
159
if (typeof item === 'string') {
160
const uri = URI.parse(item);
161
return { uri, name: basename(uri), displayType: 'letter' as ProjectBarEntryDisplayType };
162
} else {
163
const uri = URI.parse(item.uri);
164
return {
165
uri,
166
name: basename(uri),
167
displayType: item.displayType ?? 'letter',
168
iconId: item.iconId
169
};
170
}
171
});
172
} catch {
173
this.entries = [];
174
}
175
} else {
176
this.entries = [];
177
}
178
179
// The selected folder is always the first workspace folder
180
const currentFolders = this.workspaceContextService.getWorkspace().folders;
181
this._selectedFolderUri = currentFolders.length > 0 ? currentFolders[0].uri : undefined;
182
}
183
184
private saveEntriesToStorage(): void {
185
const data: IProjectBarEntryData[] = this.entries.map(e => ({
186
uri: e.uri.toString(),
187
displayType: e.displayType,
188
iconId: e.iconId
189
}));
190
this.storageService.store(PROJECT_BAR_FOLDERS_KEY, JSON.stringify(data), StorageScope.WORKSPACE, StorageTarget.MACHINE);
191
}
192
193
private addFolderEntry(uri: URI): void {
194
// Don't add duplicates
195
if (this.entries.some(e => e.uri.toString() === uri.toString())) {
196
return;
197
}
198
199
this.entries.push({ uri, name: basename(uri), displayType: 'letter' });
200
this.saveEntriesToStorage();
201
202
// Select the newly added folder
203
this._selectedFolderUri = uri;
204
this.saveEntriesToStorage();
205
this.applySelectedFolder();
206
this._onDidSelectWorkspace.fire(this._selectedFolderUri);
207
208
this.renderContent();
209
}
210
211
private async applySelectedFolder(): Promise<void> {
212
if (!this._selectedFolderUri) {
213
return;
214
}
215
216
const currentFolders = this.workspaceContextService.getWorkspace().folders;
217
const foldersToRemove = currentFolders.map(f => f.uri);
218
219
// Remove existing workspace folders and add the selected one
220
await this.workspaceEditingService.updateFolders(
221
0,
222
foldersToRemove.length,
223
[{ uri: this._selectedFolderUri }]
224
);
225
}
226
227
protected override createContentArea(parent: HTMLElement): HTMLElement {
228
this.element = parent;
229
this.content = append(this.element, $('.content'));
230
231
// Create actions container for workspace folders and add button
232
this.actionsContainer = append(this.content, $('.actions-container'));
233
234
// Create the UI for workspace folders
235
this.renderContent();
236
237
// Create global composite bar at the bottom (accounts, settings)
238
this.globalCompositeBar.create(this.content);
239
240
return this.content;
241
}
242
243
private renderContent(): void {
244
if (!this.actionsContainer) {
245
return;
246
}
247
248
// Clear existing content
249
clearNode(this.actionsContainer);
250
this.workspaceEntryDisposables.value = new DisposableStore();
251
252
// Create add folder button
253
this.createAddFolderButton(this.actionsContainer);
254
255
// Create workspace folder entries
256
this.createWorkspaceEntries(this.actionsContainer);
257
}
258
259
private createAddFolderButton(container: HTMLElement): void {
260
this.addFolderButton = append(container, $('.action-item.add-folder'));
261
const actionLabel = append(this.addFolderButton, $('span.action-label'));
262
263
// Add the plus icon using codicon
264
actionLabel.classList.add(...ThemeIcon.asClassNameArray(Codicon.add));
265
266
// Add hover tooltip
267
this.workspaceEntryDisposables.value?.add(
268
this.hoverService.setupDelayedHover(
269
this.addFolderButton,
270
{
271
appearance: { showPointer: true },
272
position: { hoverPosition: HoverPosition.RIGHT },
273
content: 'Add Folder to Project'
274
},
275
{ groupId: HOVER_GROUP_ID }
276
)
277
);
278
279
// Click handler to add folder
280
this.workspaceEntryDisposables.value?.add(
281
addDisposableListener(this.addFolderButton, EventType.CLICK, () => {
282
this.pickAndAddFolder();
283
})
284
);
285
286
// Keyboard support
287
this.addFolderButton.setAttribute('tabindex', '0');
288
this.addFolderButton.setAttribute('role', 'button');
289
this.addFolderButton.setAttribute('aria-label', 'Add Folder to Project');
290
this.workspaceEntryDisposables.value?.add(
291
addDisposableListener(this.addFolderButton, EventType.KEY_DOWN, (e: KeyboardEvent) => {
292
if (e.key === 'Enter' || e.key === ' ') {
293
e.preventDefault();
294
this.pickAndAddFolder();
295
}
296
})
297
);
298
}
299
300
private async pickAndAddFolder(): Promise<void> {
301
const folders = await this.fileDialogService.showOpenDialog({
302
openLabel: 'Add',
303
title: 'Add Folder to Project',
304
canSelectFolders: true,
305
canSelectMany: false,
306
defaultUri: await this.fileDialogService.defaultFolderPath(),
307
availableFileSystems: [this.pathService.defaultUriScheme]
308
});
309
310
if (folders?.length) {
311
this.addFolderEntry(folders[0]);
312
}
313
}
314
315
private createWorkspaceEntries(container: HTMLElement): void {
316
for (let i = 0; i < this.entries.length; i++) {
317
this.createWorkspaceEntry(container, this.entries[i], i);
318
}
319
320
// Auto-select first entry if available and none selected
321
if (this.entries.length > 0 && this._selectedFolderUri) {
322
this._onDidSelectWorkspace.fire(this._selectedFolderUri);
323
}
324
}
325
326
private createWorkspaceEntry(container: HTMLElement, entry: IProjectBarEntry, index: number): void {
327
const entryDisposables = this.workspaceEntryDisposables.value!;
328
329
const entryElement = append(container, $('.action-item.workspace-entry'));
330
const actionLabel = append(entryElement, $('span.action-label.workspace-icon'));
331
append(entryElement, $('span.active-item-indicator'));
332
333
// Render based on display type
334
const folderName = entry.name;
335
if (entry.displayType === 'icon' && entry.iconId) {
336
// Render codicon
337
const icon = ThemeIcon.fromId(entry.iconId);
338
actionLabel.classList.add(...ThemeIcon.asClassNameArray(icon));
339
actionLabel.classList.add('codicon-icon');
340
actionLabel.textContent = '';
341
} else {
342
// Default: render first letter of folder name
343
const firstLetter = folderName.charAt(0).toUpperCase();
344
actionLabel.textContent = firstLetter;
345
}
346
347
// Set selected state
348
const isSelected = this._selectedFolderUri?.toString() === entry.uri.toString();
349
if (isSelected) {
350
entryElement.classList.add('checked');
351
}
352
353
// Build hover content with full path
354
const folderPath = this.labelService.getUriLabel(entry.uri, { relative: false });
355
356
// Add hover tooltip with folder name
357
entryDisposables.add(
358
this.hoverService.setupDelayedHover(
359
entryElement,
360
{
361
appearance: { showPointer: true },
362
position: { hoverPosition: HoverPosition.RIGHT },
363
content: folderPath
364
},
365
{ groupId: HOVER_GROUP_ID }
366
)
367
);
368
369
// Click handler to select workspace
370
entryDisposables.add(
371
addDisposableListener(entryElement, EventType.CLICK, () => {
372
this.selectWorkspace(index);
373
})
374
);
375
376
// Keyboard support
377
entryElement.setAttribute('tabindex', '0');
378
entryElement.setAttribute('role', 'button');
379
entryElement.setAttribute('aria-label', folderName);
380
entryElement.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
381
entryDisposables.add(
382
addDisposableListener(entryElement, EventType.KEY_DOWN, (e: KeyboardEvent) => {
383
if (e.key === 'Enter' || e.key === ' ') {
384
e.preventDefault();
385
this.selectWorkspace(index);
386
}
387
})
388
);
389
390
// Context menu with customize and remove actions
391
entryDisposables.add(
392
addDisposableListener(entryElement, EventType.CONTEXT_MENU, (e: MouseEvent) => {
393
e.preventDefault();
394
e.stopPropagation();
395
const event = new StandardMouseEvent(getWindow(entryElement), e);
396
this.contextMenuService.showContextMenu({
397
getAnchor: () => event,
398
getActions: () => [
399
new Action('projectbar.customize', localize('projectbar.customize', "Customize"), undefined, true, () => this.showCustomizeQuickPick(index)),
400
new Separator(),
401
new Action('projectbar.removeFolder', localize('projectbar.removeFolder', "Remove Folder"), undefined, true, () => this.removeFolderEntry(index))
402
]
403
});
404
})
405
);
406
}
407
408
private selectWorkspace(index: number): void {
409
if (index < 0 || index >= this.entries.length) {
410
return;
411
}
412
413
const entry = this.entries[index];
414
if (this._selectedFolderUri?.toString() === entry.uri.toString()) {
415
return; // Already selected
416
}
417
418
this._selectedFolderUri = entry.uri;
419
this.saveEntriesToStorage();
420
421
// Re-render to update visual state
422
this.renderContent();
423
424
// Apply the selected folder as the workspace folder
425
this.applySelectedFolder();
426
427
// Fire selection event
428
this._onDidSelectWorkspace.fire(this._selectedFolderUri);
429
}
430
431
private removeFolderEntry(index: number): void {
432
if (index < 0 || index >= this.entries.length) {
433
return;
434
}
435
436
const removedUri = this.entries[index].uri;
437
this.entries.splice(index, 1);
438
this.saveEntriesToStorage();
439
440
// If the removed entry was the selected one, select the first remaining entry
441
if (this._selectedFolderUri?.toString() === removedUri.toString()) {
442
if (this.entries.length > 0) {
443
this._selectedFolderUri = this.entries[0].uri;
444
this.applySelectedFolder();
445
this._onDidSelectWorkspace.fire(this._selectedFolderUri);
446
} else {
447
this._selectedFolderUri = undefined;
448
this._onDidSelectWorkspace.fire(undefined);
449
}
450
}
451
452
this.renderContent();
453
}
454
455
private async showCustomizeQuickPick(index: number): Promise<void> {
456
if (index < 0 || index >= this.entries.length) {
457
return;
458
}
459
460
const entry = this.entries[index];
461
462
interface ICustomizeQuickPickItem extends IQuickPickItem {
463
customType: 'letter' | 'icon';
464
}
465
466
const items: ICustomizeQuickPickItem[] = [
467
{
468
customType: 'letter',
469
label: localize('projectbar.customize.letter', "Letter"),
470
description: localize('projectbar.customize.letter.description', "Show the first letter of the workspace name")
471
},
472
{
473
customType: 'icon',
474
label: localize('projectbar.customize.icon', "Icon"),
475
description: localize('projectbar.customize.icon.description', "Choose a codicon to represent the workspace")
476
}
477
];
478
479
const picked = await this.quickInputService.pick(items, {
480
placeHolder: localize('projectbar.customize.placeholder', "Choose how to display the workspace in the project bar"),
481
title: localize('projectbar.customize.title', "Customize Workspace Appearance")
482
});
483
484
if (!picked) {
485
return;
486
}
487
488
if (picked.customType === 'letter') {
489
entry.displayType = 'letter';
490
entry.iconId = undefined;
491
this.saveEntriesToStorage();
492
this.renderContent();
493
} else if (picked.customType === 'icon') {
494
const icon = await this.pickIcon();
495
if (icon) {
496
entry.displayType = 'icon';
497
entry.iconId = icon.id;
498
this.saveEntriesToStorage();
499
this.renderContent();
500
}
501
}
502
}
503
504
private async pickIcon(): Promise<ThemeIcon | undefined> {
505
const iconSelectBox = this.instantiationService.createInstance(WorkbenchIconSelectBox, {
506
icons: icons.value,
507
inputBoxStyles: defaultInputBoxStyles
508
});
509
510
const dimension = new Dimension(486, 260);
511
return new Promise<ThemeIcon | undefined>(resolve => {
512
const disposables = new DisposableStore();
513
514
disposables.add(iconSelectBox.onDidSelect(e => {
515
resolve(e);
516
disposables.dispose();
517
iconSelectBox.dispose();
518
}));
519
520
iconSelectBox.clearInput();
521
const body = getActiveDocument().body;
522
const bodyRect = body.getBoundingClientRect();
523
const hoverWidget = this.hoverService.showInstantHover({
524
content: iconSelectBox.domNode,
525
target: {
526
targetElements: [body],
527
x: bodyRect.left + (bodyRect.width - dimension.width) / 2,
528
y: bodyRect.top + this.layoutService.activeContainerOffset.top
529
},
530
position: {
531
hoverPosition: HoverPosition.BELOW,
532
},
533
persistence: {
534
sticky: true,
535
},
536
}, true);
537
538
if (hoverWidget) {
539
disposables.add(hoverWidget);
540
}
541
542
iconSelectBox.layout(dimension);
543
iconSelectBox.focus();
544
});
545
}
546
547
get selectedWorkspaceFolder(): URI | undefined {
548
return this._selectedFolderUri;
549
}
550
551
override updateStyles(): void {
552
super.updateStyles();
553
554
const container = assertReturnsDefined(this.getContainer());
555
const background = this.getColor(ACTIVITY_BAR_BACKGROUND) || '';
556
container.style.backgroundColor = background;
557
558
const borderColor = this.getColor(ACTIVITY_BAR_BORDER) || this.getColor(contrastBorder) || '';
559
container.classList.toggle('bordered', !!borderColor);
560
container.style.borderColor = borderColor ? borderColor : '';
561
}
562
563
focus(): void {
564
// Focus the add folder button (first focusable element)
565
this.addFolderButton?.focus();
566
}
567
568
focusGlobalCompositeBar(): void {
569
this.globalCompositeBar.focus();
570
}
571
572
override layout(width: number, height: number): void {
573
super.layout(width, height, 0, 0);
574
575
// The global composite bar takes some height at the bottom
576
// The actions container will take the remaining space due to CSS flex layout
577
}
578
579
toJSON(): object {
580
return {
581
type: AgenticParts.PROJECTBAR_PART
582
};
583
}
584
}
585
586