Path: blob/main/src/vs/sessions/browser/parts/projectBarPart.ts
13395 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/projectBarPart.css';6import { Part } from '../../../workbench/browser/part.js';7import { IWorkbenchLayoutService, Position } from '../../../workbench/services/layout/browser/layoutService.js';8import { IColorTheme, IThemeService } from '../../../platform/theme/common/themeService.js';9import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js';10import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js';11import { IHoverService } from '../../../platform/hover/browser/hover.js';12import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js';13import { $, addDisposableListener, append, clearNode, Dimension, EventType, getActiveDocument, getWindow } from '../../../base/browser/dom.js';14import { Emitter, Event } from '../../../base/common/event.js';15import { 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';16import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js';17import { assertReturnsDefined } from '../../../base/common/types.js';18import { ThemeIcon } from '../../../base/common/themables.js';19import { Codicon } from '../../../base/common/codicons.js';20import { codiconsLibrary } from '../../../base/common/codiconsLibrary.js';21import { Lazy } from '../../../base/common/lazy.js';22import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js';23import { GlobalCompositeBar } from '../../../workbench/browser/parts/globalCompositeBar.js';24import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';25import { IAction, Action, Separator } from '../../../base/common/actions.js';26import { URI } from '../../../base/common/uri.js';27import { IFileDialogService } from '../../../platform/dialogs/common/dialogs.js';28import { IPathService } from '../../../workbench/services/path/common/pathService.js';29import { IWorkspaceEditingService } from '../../../workbench/services/workspaces/common/workspaceEditing.js';30import { ILabelService } from '../../../platform/label/common/label.js';31import { basename } from '../../../base/common/resources.js';32import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js';33import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js';34import { IQuickInputService, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js';35import { getIconRegistry, IconContribution } from '../../../platform/theme/common/iconRegistry.js';36import { defaultInputBoxStyles } from '../../../platform/theme/browser/defaultStyles.js';37import { WorkbenchIconSelectBox } from '../../../workbench/services/userDataProfile/browser/iconSelectBox.js';38import { localize } from '../../../nls.js';39import { AgenticParts } from './parts.js';4041const HOVER_GROUP_ID = 'projectbar';42const PROJECT_BAR_FOLDERS_KEY = 'workbench.agentsession.projectbar.folders';4344type ProjectBarEntryDisplayType = 'letter' | 'icon';4546interface IProjectBarEntryData {47readonly uri: string;48readonly displayType?: ProjectBarEntryDisplayType;49readonly iconId?: string;50}5152interface IProjectBarEntry {53readonly uri: URI;54readonly name: string;55displayType: ProjectBarEntryDisplayType;56iconId?: string;57}5859const icons = new Lazy<IconContribution[]>(() => {60const iconDefinitions = getIconRegistry().getIcons();61const includedChars = new Set<string>();62const dedupedIcons = iconDefinitions.filter(e => {63if (e.id === codiconsLibrary.blank.id) {64return false;65}66if (ThemeIcon.isThemeIcon(e.defaults)) {67return false;68}69if (includedChars.has(e.defaults.fontCharacter)) {70return false;71}72includedChars.add(e.defaults.fontCharacter);73return true;74});75return dedupedIcons;76});7778/**79* ProjectBarPart displays project folder entries stored in workspace storage and allows selection between them.80* When a folder is selected, the workspace editing service is used to replace the current workspace folder81* with the selected one. It is positioned to the left of the sidebar and has the same visual style as the activity bar.82* Also includes global activities (accounts, settings) at the bottom.83*/84export class ProjectBarPart extends Part {8586static readonly ACTION_HEIGHT = 48;8788//#region IView8990readonly minimumWidth: number = 48;91readonly maximumWidth: number = 48;92readonly minimumHeight: number = 0;93readonly maximumHeight: number = Number.POSITIVE_INFINITY;9495//#endregion9697private content: HTMLElement | undefined;98private actionsContainer: HTMLElement | undefined;99private addFolderButton: HTMLElement | undefined;100private entries: IProjectBarEntry[] = [];101private _selectedFolderUri: URI | undefined;102private readonly globalCompositeBar: GlobalCompositeBar;103104private readonly workspaceEntryDisposables = this._register(new MutableDisposable<DisposableStore>());105106private readonly _onDidSelectWorkspace = this._register(new Emitter<URI | undefined>());107readonly onDidSelectWorkspace: Event<URI | undefined> = this._onDidSelectWorkspace.event;108109constructor(110@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,111@IThemeService themeService: IThemeService,112@IStorageService private readonly storageService: IStorageService,113@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,114@IFileDialogService private readonly fileDialogService: IFileDialogService,115@IPathService private readonly pathService: IPathService,116@IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService,117@ILabelService private readonly labelService: ILabelService,118@IHoverService private readonly hoverService: IHoverService,119@IContextMenuService private readonly contextMenuService: IContextMenuService,120@IQuickInputService private readonly quickInputService: IQuickInputService,121@IInstantiationService private readonly instantiationService: IInstantiationService,122) {123super(AgenticParts.PROJECTBAR_PART, { hasTitle: false }, themeService, storageService, layoutService);124125// Create the global composite bar for accounts and settings at the bottom126this.globalCompositeBar = this._register(instantiationService.createInstance(127GlobalCompositeBar,128() => this.getContextMenuActions(),129(theme: IColorTheme) => ({130activeForegroundColor: theme.getColor(ACTIVITY_BAR_FOREGROUND),131inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_INACTIVE_FOREGROUND),132badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND),133badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND),134activeBackgroundColor: undefined,135inactiveBackgroundColor: undefined,136activeBorderBottomColor: undefined,137}),138{139position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT,140}141));142143// Load entries from storage144this.loadEntriesFromStorage();145}146147private getContextMenuActions(): IAction[] {148return this.globalCompositeBar.getContextMenuActions();149}150151private loadEntriesFromStorage(): void {152const raw = this.storageService.get(PROJECT_BAR_FOLDERS_KEY, StorageScope.WORKSPACE);153if (raw) {154try {155const data: (string | IProjectBarEntryData)[] = JSON.parse(raw);156this.entries = data.map(item => {157// Support legacy format (just URIs as strings) and new format (objects with display settings)158if (typeof item === 'string') {159const uri = URI.parse(item);160return { uri, name: basename(uri), displayType: 'letter' as ProjectBarEntryDisplayType };161} else {162const uri = URI.parse(item.uri);163return {164uri,165name: basename(uri),166displayType: item.displayType ?? 'letter',167iconId: item.iconId168};169}170});171} catch {172this.entries = [];173}174} else {175this.entries = [];176}177178// The selected folder is always the first workspace folder179const currentFolders = this.workspaceContextService.getWorkspace().folders;180this._selectedFolderUri = currentFolders.length > 0 ? currentFolders[0].uri : undefined;181}182183private saveEntriesToStorage(): void {184const data: IProjectBarEntryData[] = this.entries.map(e => ({185uri: e.uri.toString(),186displayType: e.displayType,187iconId: e.iconId188}));189this.storageService.store(PROJECT_BAR_FOLDERS_KEY, JSON.stringify(data), StorageScope.WORKSPACE, StorageTarget.MACHINE);190}191192private addFolderEntry(uri: URI): void {193// Don't add duplicates194if (this.entries.some(e => e.uri.toString() === uri.toString())) {195return;196}197198this.entries.push({ uri, name: basename(uri), displayType: 'letter' });199this.saveEntriesToStorage();200201// Select the newly added folder202this._selectedFolderUri = uri;203this.saveEntriesToStorage();204this.applySelectedFolder();205this._onDidSelectWorkspace.fire(this._selectedFolderUri);206207this.renderContent();208}209210private async applySelectedFolder(): Promise<void> {211if (!this._selectedFolderUri) {212return;213}214215const currentFolders = this.workspaceContextService.getWorkspace().folders;216const foldersToRemove = currentFolders.map(f => f.uri);217218// Remove existing workspace folders and add the selected one219await this.workspaceEditingService.updateFolders(2200,221foldersToRemove.length,222[{ uri: this._selectedFolderUri }]223);224}225226protected override createContentArea(parent: HTMLElement): HTMLElement {227this.element = parent;228this.content = append(this.element, $('.content'));229230// Create actions container for workspace folders and add button231this.actionsContainer = append(this.content, $('.actions-container'));232233// Create the UI for workspace folders234this.renderContent();235236// Create global composite bar at the bottom (accounts, settings)237this.globalCompositeBar.create(this.content);238239return this.content;240}241242private renderContent(): void {243if (!this.actionsContainer) {244return;245}246247// Clear existing content248clearNode(this.actionsContainer);249this.workspaceEntryDisposables.value = new DisposableStore();250251// Create add folder button252this.createAddFolderButton(this.actionsContainer);253254// Create workspace folder entries255this.createWorkspaceEntries(this.actionsContainer);256}257258private createAddFolderButton(container: HTMLElement): void {259this.addFolderButton = append(container, $('.action-item.add-folder'));260const actionLabel = append(this.addFolderButton, $('span.action-label'));261262// Add the plus icon using codicon263actionLabel.classList.add(...ThemeIcon.asClassNameArray(Codicon.add));264265// Add hover tooltip266this.workspaceEntryDisposables.value?.add(267this.hoverService.setupDelayedHover(268this.addFolderButton,269{270appearance: { showPointer: true },271position: { hoverPosition: HoverPosition.RIGHT },272content: 'Add Folder to Project'273},274{ groupId: HOVER_GROUP_ID }275)276);277278// Click handler to add folder279this.workspaceEntryDisposables.value?.add(280addDisposableListener(this.addFolderButton, EventType.CLICK, () => {281this.pickAndAddFolder();282})283);284285// Keyboard support286this.addFolderButton.setAttribute('tabindex', '0');287this.addFolderButton.setAttribute('role', 'button');288this.addFolderButton.setAttribute('aria-label', 'Add Folder to Project');289this.workspaceEntryDisposables.value?.add(290addDisposableListener(this.addFolderButton, EventType.KEY_DOWN, (e: KeyboardEvent) => {291if (e.key === 'Enter' || e.key === ' ') {292e.preventDefault();293this.pickAndAddFolder();294}295})296);297}298299private async pickAndAddFolder(): Promise<void> {300const folders = await this.fileDialogService.showOpenDialog({301openLabel: 'Add',302title: 'Add Folder to Project',303canSelectFolders: true,304canSelectMany: false,305defaultUri: await this.fileDialogService.defaultFolderPath(),306availableFileSystems: [this.pathService.defaultUriScheme]307});308309if (folders?.length) {310this.addFolderEntry(folders[0]);311}312}313314private createWorkspaceEntries(container: HTMLElement): void {315for (let i = 0; i < this.entries.length; i++) {316this.createWorkspaceEntry(container, this.entries[i], i);317}318319// Auto-select first entry if available and none selected320if (this.entries.length > 0 && this._selectedFolderUri) {321this._onDidSelectWorkspace.fire(this._selectedFolderUri);322}323}324325private createWorkspaceEntry(container: HTMLElement, entry: IProjectBarEntry, index: number): void {326const entryDisposables = this.workspaceEntryDisposables.value!;327328const entryElement = append(container, $('.action-item.workspace-entry'));329const actionLabel = append(entryElement, $('span.action-label.workspace-icon'));330append(entryElement, $('span.active-item-indicator'));331332// Render based on display type333const folderName = entry.name;334if (entry.displayType === 'icon' && entry.iconId) {335// Render codicon336const icon = ThemeIcon.fromId(entry.iconId);337actionLabel.classList.add(...ThemeIcon.asClassNameArray(icon));338actionLabel.classList.add('codicon-icon');339actionLabel.textContent = '';340} else {341// Default: render first letter of folder name342const firstLetter = folderName.charAt(0).toUpperCase();343actionLabel.textContent = firstLetter;344}345346// Set selected state347const isSelected = this._selectedFolderUri?.toString() === entry.uri.toString();348if (isSelected) {349entryElement.classList.add('checked');350}351352// Build hover content with full path353const folderPath = this.labelService.getUriLabel(entry.uri, { relative: false });354355// Add hover tooltip with folder name356entryDisposables.add(357this.hoverService.setupDelayedHover(358entryElement,359{360appearance: { showPointer: true },361position: { hoverPosition: HoverPosition.RIGHT },362content: folderPath363},364{ groupId: HOVER_GROUP_ID }365)366);367368// Click handler to select workspace369entryDisposables.add(370addDisposableListener(entryElement, EventType.CLICK, () => {371this.selectWorkspace(index);372})373);374375// Keyboard support376entryElement.setAttribute('tabindex', '0');377entryElement.setAttribute('role', 'button');378entryElement.setAttribute('aria-label', folderName);379entryElement.setAttribute('aria-pressed', isSelected ? 'true' : 'false');380entryDisposables.add(381addDisposableListener(entryElement, EventType.KEY_DOWN, (e: KeyboardEvent) => {382if (e.key === 'Enter' || e.key === ' ') {383e.preventDefault();384this.selectWorkspace(index);385}386})387);388389// Context menu with customize and remove actions390entryDisposables.add(391addDisposableListener(entryElement, EventType.CONTEXT_MENU, (e: MouseEvent) => {392e.preventDefault();393e.stopPropagation();394const event = new StandardMouseEvent(getWindow(entryElement), e);395this.contextMenuService.showContextMenu({396getAnchor: () => event,397getActions: () => [398new Action('projectbar.customize', localize('projectbar.customize', "Customize"), undefined, true, () => this.showCustomizeQuickPick(index)),399new Separator(),400new Action('projectbar.removeFolder', localize('projectbar.removeFolder', "Remove Folder"), undefined, true, () => this.removeFolderEntry(index))401]402});403})404);405}406407private selectWorkspace(index: number): void {408if (index < 0 || index >= this.entries.length) {409return;410}411412const entry = this.entries[index];413if (this._selectedFolderUri?.toString() === entry.uri.toString()) {414return; // Already selected415}416417this._selectedFolderUri = entry.uri;418this.saveEntriesToStorage();419420// Re-render to update visual state421this.renderContent();422423// Apply the selected folder as the workspace folder424this.applySelectedFolder();425426// Fire selection event427this._onDidSelectWorkspace.fire(this._selectedFolderUri);428}429430private removeFolderEntry(index: number): void {431if (index < 0 || index >= this.entries.length) {432return;433}434435const removedUri = this.entries[index].uri;436this.entries.splice(index, 1);437this.saveEntriesToStorage();438439// If the removed entry was the selected one, select the first remaining entry440if (this._selectedFolderUri?.toString() === removedUri.toString()) {441if (this.entries.length > 0) {442this._selectedFolderUri = this.entries[0].uri;443this.applySelectedFolder();444this._onDidSelectWorkspace.fire(this._selectedFolderUri);445} else {446this._selectedFolderUri = undefined;447this._onDidSelectWorkspace.fire(undefined);448}449}450451this.renderContent();452}453454private async showCustomizeQuickPick(index: number): Promise<void> {455if (index < 0 || index >= this.entries.length) {456return;457}458459const entry = this.entries[index];460461interface ICustomizeQuickPickItem extends IQuickPickItem {462customType: 'letter' | 'icon';463}464465const items: ICustomizeQuickPickItem[] = [466{467customType: 'letter',468label: localize('projectbar.customize.letter', "Letter"),469description: localize('projectbar.customize.letter.description', "Show the first letter of the workspace name")470},471{472customType: 'icon',473label: localize('projectbar.customize.icon', "Icon"),474description: localize('projectbar.customize.icon.description', "Choose a codicon to represent the workspace")475}476];477478const picked = await this.quickInputService.pick(items, {479placeHolder: localize('projectbar.customize.placeholder', "Choose how to display the workspace in the project bar"),480title: localize('projectbar.customize.title', "Customize Workspace Appearance")481});482483if (!picked) {484return;485}486487if (picked.customType === 'letter') {488entry.displayType = 'letter';489entry.iconId = undefined;490this.saveEntriesToStorage();491this.renderContent();492} else if (picked.customType === 'icon') {493const icon = await this.pickIcon();494if (icon) {495entry.displayType = 'icon';496entry.iconId = icon.id;497this.saveEntriesToStorage();498this.renderContent();499}500}501}502503private async pickIcon(): Promise<ThemeIcon | undefined> {504const iconSelectBox = this.instantiationService.createInstance(WorkbenchIconSelectBox, {505icons: icons.value,506inputBoxStyles: defaultInputBoxStyles507});508509const dimension = new Dimension(486, 260);510return new Promise<ThemeIcon | undefined>(resolve => {511const disposables = new DisposableStore();512513disposables.add(iconSelectBox.onDidSelect(e => {514resolve(e);515disposables.dispose();516iconSelectBox.dispose();517}));518519iconSelectBox.clearInput();520const body = getActiveDocument().body;521const bodyRect = body.getBoundingClientRect();522const hoverWidget = this.hoverService.showInstantHover({523content: iconSelectBox.domNode,524target: {525targetElements: [body],526x: bodyRect.left + (bodyRect.width - dimension.width) / 2,527y: bodyRect.top + this.layoutService.activeContainerOffset.top528},529position: {530hoverPosition: HoverPosition.BELOW,531},532persistence: {533sticky: true,534},535}, true);536537if (hoverWidget) {538disposables.add(hoverWidget);539}540541iconSelectBox.layout(dimension);542iconSelectBox.focus();543});544}545546get selectedWorkspaceFolder(): URI | undefined {547return this._selectedFolderUri;548}549550override updateStyles(): void {551super.updateStyles();552553const container = assertReturnsDefined(this.getContainer());554const background = this.getColor(ACTIVITY_BAR_BACKGROUND) || '';555container.style.backgroundColor = background;556557const borderColor = this.getColor(ACTIVITY_BAR_BORDER) || this.getColor(contrastBorder) || '';558container.classList.toggle('bordered', !!borderColor);559container.style.borderColor = borderColor ? borderColor : '';560}561562focus(): void {563// Focus the add folder button (first focusable element)564this.addFolderButton?.focus();565}566567focusGlobalCompositeBar(): void {568this.globalCompositeBar.focus();569}570571override layout(width: number, height: number): void {572super.layout(width, height, 0, 0);573574// The global composite bar takes some height at the bottom575// The actions container will take the remaining space due to CSS flex layout576}577578toJSON(): object {579return {580type: AgenticParts.PROJECTBAR_PART581};582}583}584585586