Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/chat/browser/repoPicker.ts
13401 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 * as dom from '../../../../base/browser/dom.js';
7
import { Codicon } from '../../../../base/common/codicons.js';
8
import { Emitter, Event } from '../../../../base/common/event.js';
9
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { localize } from '../../../../nls.js';
11
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
12
import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js';
13
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
14
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
15
import { ICommandService } from '../../../../platform/commands/common/commands.js';
16
17
const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository';
18
const STORAGE_KEY_LAST_REPO = 'agentSessions.lastPickedRepo';
19
const STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos';
20
const MAX_RECENT_REPOS = 10;
21
const FILTER_THRESHOLD = 10;
22
23
interface IRepoItem {
24
readonly id: string;
25
readonly name: string;
26
}
27
28
/**
29
* A self-contained widget for selecting the repository in cloud sessions.
30
* Uses the `github.copilot.chat.cloudSessions.openRepository` command for
31
* browsing repositories. Manages recently used repos in storage.
32
* Behaves like FolderPicker: trigger button with dropdown, storage persistence,
33
* recently used list with remove buttons.
34
*/
35
export class RepoPicker extends Disposable {
36
37
private readonly _onDidSelectRepo = this._register(new Emitter<string>());
38
readonly onDidSelectRepo: Event<string> = this._onDidSelectRepo.event;
39
40
private _triggerElement: HTMLElement | undefined;
41
private readonly _renderDisposables = this._register(new DisposableStore());
42
43
private _selectedRepo: IRepoItem | undefined;
44
private _recentlyPickedRepos: IRepoItem[] = [];
45
46
get selectedRepo(): string | undefined {
47
return this._selectedRepo?.id;
48
}
49
50
constructor(
51
@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,
52
@IStorageService private readonly storageService: IStorageService,
53
@ICommandService private readonly commandService: ICommandService,
54
) {
55
super();
56
57
// Restore last picked repo
58
try {
59
const last = this.storageService.get(STORAGE_KEY_LAST_REPO, StorageScope.PROFILE);
60
if (last) {
61
this._selectedRepo = JSON.parse(last);
62
}
63
} catch { /* ignore */ }
64
65
// Restore recently picked repos
66
try {
67
const stored = this.storageService.get(STORAGE_KEY_RECENT_REPOS, StorageScope.PROFILE);
68
if (stored) {
69
this._recentlyPickedRepos = JSON.parse(stored);
70
}
71
} catch { /* ignore */ }
72
}
73
74
/**
75
* Renders the repo picker trigger button into the given container.
76
* Returns the container element.
77
*/
78
render(container: HTMLElement): HTMLElement {
79
this._renderDisposables.clear();
80
81
const slot = dom.append(container, dom.$('.sessions-chat-picker-slot'));
82
this._renderDisposables.add({ dispose: () => slot.remove() });
83
84
const trigger = dom.append(slot, dom.$('a.action-label'));
85
trigger.tabIndex = 0;
86
trigger.role = 'button';
87
this._triggerElement = trigger;
88
89
this._updateTriggerLabel();
90
91
this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => {
92
dom.EventHelper.stop(e, true);
93
this.showPicker();
94
}));
95
96
this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => {
97
if (e.key === 'Enter' || e.key === ' ') {
98
dom.EventHelper.stop(e, true);
99
this.showPicker();
100
}
101
}));
102
103
return slot;
104
}
105
106
/**
107
* Shows the repo picker dropdown anchored to the trigger element.
108
*/
109
showPicker(): void {
110
if (!this._triggerElement || this.actionWidgetService.isVisible) {
111
return;
112
}
113
114
const items = this._buildItems();
115
const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD;
116
117
const triggerElement = this._triggerElement;
118
const delegate: IActionListDelegate<IRepoItem> = {
119
onSelect: (item) => {
120
this.actionWidgetService.hide();
121
if (item.id === 'browse') {
122
this._browseForRepo();
123
} else {
124
this._selectRepo(item);
125
}
126
},
127
onHide: () => { triggerElement.focus(); },
128
};
129
130
this.actionWidgetService.show<IRepoItem>(
131
'repoPicker',
132
false,
133
items,
134
delegate,
135
this._triggerElement,
136
undefined,
137
[],
138
{
139
getAriaLabel: (item) => item.label ?? '',
140
getWidgetAriaLabel: () => localize('repoPicker.ariaLabel', "Repository Picker"),
141
},
142
showFilter ? { showFilter: true, filterPlaceholder: localize('repoPicker.filter', "Filter repositories...") } : undefined,
143
);
144
}
145
146
/**
147
* Programmatically set the selected repository.
148
*/
149
setSelectedRepo(repoPath: string): void {
150
this._selectRepo({ id: repoPath, name: repoPath });
151
}
152
153
/**
154
* Clears the selected repository.
155
*/
156
clearSelection(): void {
157
this._selectedRepo = undefined;
158
this._updateTriggerLabel();
159
}
160
161
private _selectRepo(item: IRepoItem): void {
162
this._selectedRepo = item;
163
this._addToRecentlyPicked(item);
164
this.storageService.store(STORAGE_KEY_LAST_REPO, JSON.stringify(item), StorageScope.PROFILE, StorageTarget.MACHINE);
165
this._updateTriggerLabel();
166
this._onDidSelectRepo.fire(item.id);
167
}
168
169
private async _browseForRepo(): Promise<void> {
170
try {
171
const result: string | undefined = await this.commandService.executeCommand(OPEN_REPO_COMMAND);
172
if (result) {
173
this._selectRepo({ id: result, name: result });
174
}
175
} catch {
176
// command was cancelled or failed — nothing to do
177
}
178
}
179
180
private _addToRecentlyPicked(item: IRepoItem): void {
181
this._recentlyPickedRepos = [
182
{ id: item.id, name: item.name },
183
...this._recentlyPickedRepos.filter(r => r.id !== item.id),
184
].slice(0, MAX_RECENT_REPOS);
185
this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE);
186
}
187
188
private _buildItems(): IActionListItem<IRepoItem>[] {
189
const seenIds = new Set<string>();
190
const items: IActionListItem<IRepoItem>[] = [];
191
192
// Currently selected (shown first, checked)
193
if (this._selectedRepo) {
194
seenIds.add(this._selectedRepo.id);
195
items.push({
196
kind: ActionListItemKind.Action,
197
label: this._selectedRepo.name,
198
group: { title: '', icon: Codicon.repo },
199
item: this._selectedRepo,
200
});
201
}
202
203
// Recently picked repos (sorted by name)
204
const dedupedRepos = this._recentlyPickedRepos.filter(r => !seenIds.has(r.id));
205
dedupedRepos.sort((a, b) => a.name.localeCompare(b.name));
206
for (const repo of dedupedRepos) {
207
seenIds.add(repo.id);
208
items.push({
209
kind: ActionListItemKind.Action,
210
label: repo.name,
211
group: { title: '', icon: Codicon.repo },
212
item: repo,
213
onRemove: () => this._removeRepo(repo.id),
214
});
215
}
216
217
// Separator + Browse...
218
if (items.length > 0) {
219
items.push({ kind: ActionListItemKind.Separator, label: '' });
220
}
221
items.push({
222
kind: ActionListItemKind.Action,
223
label: localize('browseRepo', "Browse..."),
224
group: { title: '', icon: Codicon.search },
225
item: { id: 'browse', name: localize('browseRepo', "Browse...") },
226
});
227
228
return items;
229
}
230
231
private _removeRepo(repoId: string): void {
232
this._recentlyPickedRepos = this._recentlyPickedRepos.filter(r => r.id !== repoId);
233
this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE);
234
235
// Re-show picker with updated items
236
this.actionWidgetService.hide();
237
this.showPicker();
238
}
239
240
private _updateTriggerLabel(): void {
241
if (!this._triggerElement) {
242
return;
243
}
244
245
dom.clearNode(this._triggerElement);
246
const label = this._selectedRepo?.name ?? localize('pickRepo', "Pick Repository");
247
248
dom.append(this._triggerElement, renderIcon(Codicon.repo));
249
const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label'));
250
labelSpan.textContent = label;
251
dom.append(this._triggerElement, renderIcon(Codicon.chevronDown));
252
253
this._triggerElement.ariaLabel = localize('repoPicker.triggerAriaLabel', "Pick Repository, {0}", label);
254
}
255
256
}
257
258