Path: blob/main/src/vs/sessions/contrib/chat/browser/repoPicker.ts
13401 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 * as dom from '../../../../base/browser/dom.js';6import { Codicon } from '../../../../base/common/codicons.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';9import { localize } from '../../../../nls.js';10import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';11import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';12import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';13import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';14import { ICommandService } from '../../../../platform/commands/common/commands.js';1516const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository';17const STORAGE_KEY_LAST_REPO = 'agentSessions.lastPickedRepo';18const STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos';19const MAX_RECENT_REPOS = 10;20const FILTER_THRESHOLD = 10;2122interface IRepoItem {23readonly id: string;24readonly name: string;25}2627/**28* A self-contained widget for selecting the repository in cloud sessions.29* Uses the `github.copilot.chat.cloudSessions.openRepository` command for30* browsing repositories. Manages recently used repos in storage.31* Behaves like FolderPicker: trigger button with dropdown, storage persistence,32* recently used list with remove buttons.33*/34export class RepoPicker extends Disposable {3536private readonly _onDidSelectRepo = this._register(new Emitter<string>());37readonly onDidSelectRepo: Event<string> = this._onDidSelectRepo.event;3839private _triggerElement: HTMLElement | undefined;40private readonly _renderDisposables = this._register(new DisposableStore());4142private _selectedRepo: IRepoItem | undefined;43private _recentlyPickedRepos: IRepoItem[] = [];4445get selectedRepo(): string | undefined {46return this._selectedRepo?.id;47}4849constructor(50@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,51@IStorageService private readonly storageService: IStorageService,52@ICommandService private readonly commandService: ICommandService,53) {54super();5556// Restore last picked repo57try {58const last = this.storageService.get(STORAGE_KEY_LAST_REPO, StorageScope.PROFILE);59if (last) {60this._selectedRepo = JSON.parse(last);61}62} catch { /* ignore */ }6364// Restore recently picked repos65try {66const stored = this.storageService.get(STORAGE_KEY_RECENT_REPOS, StorageScope.PROFILE);67if (stored) {68this._recentlyPickedRepos = JSON.parse(stored);69}70} catch { /* ignore */ }71}7273/**74* Renders the repo picker trigger button into the given container.75* Returns the container element.76*/77render(container: HTMLElement): HTMLElement {78this._renderDisposables.clear();7980const slot = dom.append(container, dom.$('.sessions-chat-picker-slot'));81this._renderDisposables.add({ dispose: () => slot.remove() });8283const trigger = dom.append(slot, dom.$('a.action-label'));84trigger.tabIndex = 0;85trigger.role = 'button';86this._triggerElement = trigger;8788this._updateTriggerLabel();8990this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => {91dom.EventHelper.stop(e, true);92this.showPicker();93}));9495this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => {96if (e.key === 'Enter' || e.key === ' ') {97dom.EventHelper.stop(e, true);98this.showPicker();99}100}));101102return slot;103}104105/**106* Shows the repo picker dropdown anchored to the trigger element.107*/108showPicker(): void {109if (!this._triggerElement || this.actionWidgetService.isVisible) {110return;111}112113const items = this._buildItems();114const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD;115116const triggerElement = this._triggerElement;117const delegate: IActionListDelegate<IRepoItem> = {118onSelect: (item) => {119this.actionWidgetService.hide();120if (item.id === 'browse') {121this._browseForRepo();122} else {123this._selectRepo(item);124}125},126onHide: () => { triggerElement.focus(); },127};128129this.actionWidgetService.show<IRepoItem>(130'repoPicker',131false,132items,133delegate,134this._triggerElement,135undefined,136[],137{138getAriaLabel: (item) => item.label ?? '',139getWidgetAriaLabel: () => localize('repoPicker.ariaLabel', "Repository Picker"),140},141showFilter ? { showFilter: true, filterPlaceholder: localize('repoPicker.filter', "Filter repositories...") } : undefined,142);143}144145/**146* Programmatically set the selected repository.147*/148setSelectedRepo(repoPath: string): void {149this._selectRepo({ id: repoPath, name: repoPath });150}151152/**153* Clears the selected repository.154*/155clearSelection(): void {156this._selectedRepo = undefined;157this._updateTriggerLabel();158}159160private _selectRepo(item: IRepoItem): void {161this._selectedRepo = item;162this._addToRecentlyPicked(item);163this.storageService.store(STORAGE_KEY_LAST_REPO, JSON.stringify(item), StorageScope.PROFILE, StorageTarget.MACHINE);164this._updateTriggerLabel();165this._onDidSelectRepo.fire(item.id);166}167168private async _browseForRepo(): Promise<void> {169try {170const result: string | undefined = await this.commandService.executeCommand(OPEN_REPO_COMMAND);171if (result) {172this._selectRepo({ id: result, name: result });173}174} catch {175// command was cancelled or failed — nothing to do176}177}178179private _addToRecentlyPicked(item: IRepoItem): void {180this._recentlyPickedRepos = [181{ id: item.id, name: item.name },182...this._recentlyPickedRepos.filter(r => r.id !== item.id),183].slice(0, MAX_RECENT_REPOS);184this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE);185}186187private _buildItems(): IActionListItem<IRepoItem>[] {188const seenIds = new Set<string>();189const items: IActionListItem<IRepoItem>[] = [];190191// Currently selected (shown first, checked)192if (this._selectedRepo) {193seenIds.add(this._selectedRepo.id);194items.push({195kind: ActionListItemKind.Action,196label: this._selectedRepo.name,197group: { title: '', icon: Codicon.repo },198item: this._selectedRepo,199});200}201202// Recently picked repos (sorted by name)203const dedupedRepos = this._recentlyPickedRepos.filter(r => !seenIds.has(r.id));204dedupedRepos.sort((a, b) => a.name.localeCompare(b.name));205for (const repo of dedupedRepos) {206seenIds.add(repo.id);207items.push({208kind: ActionListItemKind.Action,209label: repo.name,210group: { title: '', icon: Codicon.repo },211item: repo,212onRemove: () => this._removeRepo(repo.id),213});214}215216// Separator + Browse...217if (items.length > 0) {218items.push({ kind: ActionListItemKind.Separator, label: '' });219}220items.push({221kind: ActionListItemKind.Action,222label: localize('browseRepo', "Browse..."),223group: { title: '', icon: Codicon.search },224item: { id: 'browse', name: localize('browseRepo', "Browse...") },225});226227return items;228}229230private _removeRepo(repoId: string): void {231this._recentlyPickedRepos = this._recentlyPickedRepos.filter(r => r.id !== repoId);232this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE);233234// Re-show picker with updated items235this.actionWidgetService.hide();236this.showPicker();237}238239private _updateTriggerLabel(): void {240if (!this._triggerElement) {241return;242}243244dom.clearNode(this._triggerElement);245const label = this._selectedRepo?.name ?? localize('pickRepo', "Pick Repository");246247dom.append(this._triggerElement, renderIcon(Codicon.repo));248const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label'));249labelSpan.textContent = label;250dom.append(this._triggerElement, renderIcon(Codicon.chevronDown));251252this._triggerElement.ariaLabel = localize('repoPicker.triggerAriaLabel', "Pick Repository, {0}", label);253}254255}256257258