Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.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 './media/aiCustomizationTreeView.css';
7
import * as dom from '../../../../base/browser/dom.js';
8
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
9
import { CancellationToken } from '../../../../base/common/cancellation.js';
10
import { DisposableStore } from '../../../../base/common/lifecycle.js';
11
import { autorun } from '../../../../base/common/observable.js';
12
import { basename, dirname } from '../../../../base/common/resources.js';
13
import { ThemeIcon } from '../../../../base/common/themables.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { localize } from '../../../../nls.js';
16
import { createActionViewItem, getContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
17
import { IMenuService } from '../../../../platform/actions/common/actions.js';
18
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
19
import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
20
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
21
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
22
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
23
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
24
import { WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js';
25
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
26
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
27
import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js';
28
import { IViewDescriptorService } from '../../../../workbench/common/views.js';
29
import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';
30
import { ResourceSet } from '../../../../base/common/map.js';
31
import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';
32
import { agentIcon, extensionIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon, builtinIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js';
33
import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js';
34
import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js';
35
import { AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js';
36
import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js';
37
import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js';
38
import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js';
39
import { FuzzyScore } from '../../../../base/common/filters.js';
40
import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
41
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
42
import { ILogService } from '../../../../platform/log/common/log.js';
43
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
44
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
45
46
//#region Context Keys
47
48
/**
49
* Context key indicating whether the AI Customization view has no items.
50
*/
51
export const AICustomizationIsEmptyContextKey = new RawContextKey<boolean>('aiCustomization.isEmpty', true);
52
53
/**
54
* Context key for the current item's prompt type in context menus.
55
*/
56
export const AICustomizationItemTypeContextKey = new RawContextKey<string>('aiCustomizationItemType', '');
57
58
/**
59
* Context key indicating whether the current item is disabled.
60
*/
61
export const AICustomizationItemDisabledContextKey = new RawContextKey<boolean>('aiCustomizationItemDisabled', false);
62
63
/**
64
* Context key for the current item's storage type in context menus.
65
*/
66
export const AICustomizationItemStorageContextKey = new RawContextKey<string>('aiCustomizationItemStorage', '');
67
68
//#endregion
69
70
//#region Tree Item Types
71
72
/**
73
* Root element marker for the tree.
74
*/
75
const ROOT_ELEMENT = Symbol('root');
76
type RootElement = typeof ROOT_ELEMENT;
77
78
/**
79
* Represents a type category in the tree (e.g., "Custom Agents", "Skills").
80
*/
81
interface IAICustomizationTypeItem {
82
readonly type: 'category';
83
readonly id: string;
84
readonly label: string;
85
readonly promptType: PromptsType;
86
readonly icon: ThemeIcon;
87
}
88
89
/**
90
* Represents a storage group header in the tree (e.g., "Workspace", "User", "Extensions").
91
*/
92
interface IAICustomizationGroupItem {
93
readonly type: 'group';
94
readonly id: string;
95
readonly label: string;
96
readonly storage: AICustomizationPromptsStorage;
97
readonly promptType: PromptsType;
98
readonly icon: ThemeIcon;
99
}
100
101
/**
102
* Represents an individual AI customization item (agent, skill, instruction, or prompt).
103
*/
104
interface IAICustomizationFileItem {
105
readonly type: 'file';
106
readonly id: string;
107
readonly uri: URI;
108
readonly name: string;
109
readonly description?: string;
110
readonly storage: AICustomizationPromptsStorage;
111
readonly promptType: PromptsType;
112
readonly disabled: boolean;
113
}
114
115
/**
116
* Represents a link item that navigates to the management editor.
117
*/
118
interface IAICustomizationLinkItem {
119
readonly type: 'link';
120
readonly id: string;
121
readonly label: string;
122
readonly icon: ThemeIcon;
123
readonly section: AICustomizationManagementSection;
124
}
125
126
type AICustomizationTreeItem = IAICustomizationTypeItem | IAICustomizationGroupItem | IAICustomizationFileItem | IAICustomizationLinkItem;
127
128
//#endregion
129
130
//#region Tree Infrastructure
131
132
class AICustomizationTreeDelegate implements IListVirtualDelegate<AICustomizationTreeItem> {
133
getHeight(_element: AICustomizationTreeItem): number {
134
return 22;
135
}
136
137
getTemplateId(element: AICustomizationTreeItem): string {
138
switch (element.type) {
139
case 'category':
140
case 'link':
141
return 'category';
142
case 'group':
143
return 'group';
144
case 'file':
145
return 'file';
146
}
147
}
148
}
149
150
interface ICategoryTemplateData {
151
readonly container: HTMLElement;
152
readonly icon: HTMLElement;
153
readonly label: HTMLElement;
154
}
155
156
interface IGroupTemplateData {
157
readonly container: HTMLElement;
158
readonly label: HTMLElement;
159
}
160
161
interface IFileTemplateData {
162
readonly container: HTMLElement;
163
readonly icon: HTMLElement;
164
readonly name: HTMLElement;
165
readonly actionBar: ActionBar;
166
readonly elementDisposables: DisposableStore;
167
readonly templateDisposables: DisposableStore;
168
}
169
170
class AICustomizationCategoryRenderer implements ITreeRenderer<IAICustomizationTypeItem | IAICustomizationLinkItem, FuzzyScore, ICategoryTemplateData> {
171
readonly templateId = 'category';
172
173
renderTemplate(container: HTMLElement): ICategoryTemplateData {
174
const element = dom.append(container, dom.$('.ai-customization-category'));
175
const icon = dom.append(element, dom.$('.icon'));
176
const label = dom.append(element, dom.$('.label'));
177
return { container: element, icon, label };
178
}
179
180
renderElement(node: ITreeNode<IAICustomizationTypeItem | IAICustomizationLinkItem, FuzzyScore>, _index: number, templateData: ICategoryTemplateData): void {
181
templateData.icon.className = 'icon';
182
templateData.icon.classList.add(...ThemeIcon.asClassNameArray(node.element.icon));
183
templateData.label.textContent = node.element.label;
184
}
185
186
disposeTemplate(_templateData: ICategoryTemplateData): void { }
187
}
188
189
class AICustomizationGroupRenderer implements ITreeRenderer<IAICustomizationGroupItem, FuzzyScore, IGroupTemplateData> {
190
readonly templateId = 'group';
191
192
renderTemplate(container: HTMLElement): IGroupTemplateData {
193
const element = dom.append(container, dom.$('.ai-customization-group-header'));
194
const label = dom.append(element, dom.$('.label'));
195
return { container: element, label };
196
}
197
198
renderElement(node: ITreeNode<IAICustomizationGroupItem, FuzzyScore>, _index: number, templateData: IGroupTemplateData): void {
199
templateData.label.textContent = node.element.label;
200
}
201
202
disposeTemplate(_templateData: IGroupTemplateData): void { }
203
}
204
205
class AICustomizationFileRenderer implements ITreeRenderer<IAICustomizationFileItem, FuzzyScore, IFileTemplateData> {
206
readonly templateId = 'file';
207
208
constructor(
209
private readonly menuService: IMenuService,
210
private readonly contextKeyService: IContextKeyService,
211
private readonly instantiationService: IInstantiationService,
212
) { }
213
214
renderTemplate(container: HTMLElement): IFileTemplateData {
215
const element = dom.append(container, dom.$('.ai-customization-tree-item'));
216
const icon = dom.append(element, dom.$('.icon'));
217
const name = dom.append(element, dom.$('.name'));
218
const actionsContainer = dom.append(element, dom.$('.actions'));
219
220
const templateDisposables = new DisposableStore();
221
const actionBar = templateDisposables.add(new ActionBar(actionsContainer, {
222
actionViewItemProvider: createActionViewItem.bind(undefined, this.instantiationService),
223
}));
224
225
return { container: element, icon, name, actionBar, elementDisposables: new DisposableStore(), templateDisposables };
226
}
227
228
renderElement(node: ITreeNode<IAICustomizationFileItem, FuzzyScore>, _index: number, templateData: IFileTemplateData): void {
229
const item = node.element;
230
templateData.elementDisposables.clear();
231
232
// Set icon based on prompt type
233
let icon: ThemeIcon;
234
switch (item.promptType) {
235
case PromptsType.agent:
236
icon = agentIcon;
237
break;
238
case PromptsType.skill:
239
icon = skillIcon;
240
break;
241
case PromptsType.instructions:
242
icon = instructionsIcon;
243
break;
244
case PromptsType.prompt:
245
default:
246
icon = promptIcon;
247
break;
248
}
249
250
templateData.icon.className = 'icon';
251
templateData.icon.classList.add(...ThemeIcon.asClassNameArray(icon));
252
253
templateData.name.textContent = item.name;
254
255
// Apply disabled styling
256
templateData.container.classList.toggle('disabled', item.disabled);
257
258
// Set tooltip with name and description
259
const tooltip = item.description ? `${item.name} - ${item.description}` : item.name;
260
templateData.container.title = tooltip;
261
262
// Build context for menu actions
263
const context = {
264
uri: item.uri.toString(),
265
name: item.name,
266
promptType: item.promptType,
267
storage: item.storage,
268
};
269
270
// Create scoped context key service with item type for when-clause filtering
271
const overlay = this.contextKeyService.createOverlay([
272
[AICustomizationItemTypeContextKey.key, item.promptType],
273
[AICustomizationItemDisabledContextKey.key, item.disabled],
274
[AICustomizationItemStorageContextKey.key, item.storage],
275
]);
276
277
// Create menu and extract inline actions
278
const menu = templateData.elementDisposables.add(
279
this.menuService.createMenu(AICustomizationItemMenuId, overlay)
280
);
281
282
const updateActions = () => {
283
const actions = menu.getActions({ arg: context, shouldForwardArgs: true });
284
const { primary } = getContextMenuActions(actions, 'inline');
285
templateData.actionBar.clear();
286
templateData.actionBar.push(primary, { icon: true, label: false });
287
};
288
updateActions();
289
templateData.elementDisposables.add(menu.onDidChange(updateActions));
290
291
templateData.actionBar.context = context;
292
}
293
294
disposeElement(_node: ITreeNode<IAICustomizationFileItem, FuzzyScore>, _index: number, templateData: IFileTemplateData): void {
295
templateData.elementDisposables.clear();
296
}
297
298
disposeTemplate(templateData: IFileTemplateData): void {
299
templateData.templateDisposables.dispose();
300
templateData.elementDisposables.dispose();
301
}
302
}
303
304
/**
305
* Cached data for a specific prompt type.
306
*/
307
interface ICachedTypeData {
308
skills?: IAgentSkill[];
309
files?: Map<string, readonly IPromptPath[]>;
310
}
311
312
/**
313
* Data source for the AI Customization tree with efficient caching.
314
* Caches data per-type to avoid redundant fetches when expanding groups.
315
*/
316
class UnifiedAICustomizationDataSource implements IAsyncDataSource<RootElement, AICustomizationTreeItem> {
317
private cache = new Map<PromptsType, ICachedTypeData>();
318
private totalItemCount = 0;
319
320
constructor(
321
private readonly promptsService: IPromptsService,
322
private readonly logService: ILogService,
323
private readonly onItemCountChanged: (count: number) => void,
324
) { }
325
326
/**
327
* Clears the cache. Should be called when the view refreshes.
328
*/
329
clearCache(): void {
330
this.cache.clear();
331
this.totalItemCount = 0;
332
}
333
334
hasChildren(element: RootElement | AICustomizationTreeItem): boolean {
335
if (element === ROOT_ELEMENT) {
336
return true;
337
}
338
if (element.type === 'link') {
339
return false;
340
}
341
return element.type === 'category' || element.type === 'group';
342
}
343
344
async getChildren(element: RootElement | AICustomizationTreeItem): Promise<AICustomizationTreeItem[]> {
345
try {
346
if (element === ROOT_ELEMENT) {
347
return this.getTypeCategories();
348
}
349
350
if (element.type === 'category') {
351
return this.getStorageGroups(element.promptType);
352
}
353
354
if (element.type === 'group') {
355
return this.getFilesForStorageAndType(element.storage, element.promptType);
356
}
357
358
return [];
359
} catch (error) {
360
this.logService.error('[AICustomization] Error fetching tree children:', error);
361
return [];
362
}
363
}
364
365
private getTypeCategories(): (IAICustomizationTypeItem | IAICustomizationLinkItem)[] {
366
return [
367
{
368
type: 'category',
369
id: 'category-agents',
370
label: localize('customAgents', "Custom Agents"),
371
promptType: PromptsType.agent,
372
icon: agentIcon,
373
},
374
{
375
type: 'category',
376
id: 'category-skills',
377
label: localize('skills', "Skills"),
378
promptType: PromptsType.skill,
379
icon: skillIcon,
380
},
381
{
382
type: 'category',
383
id: 'category-instructions',
384
label: localize('instructions', "Instructions"),
385
promptType: PromptsType.instructions,
386
icon: instructionsIcon,
387
},
388
{
389
type: 'link',
390
id: 'link-mcp-servers',
391
label: localize('mcpServers', "MCP Servers"),
392
icon: mcpServerIcon,
393
section: AICustomizationManagementSection.McpServers,
394
},
395
];
396
}
397
398
/**
399
* Fetches and caches data for a prompt type, returning storage groups with items.
400
*/
401
private async getStorageGroups(promptType: PromptsType): Promise<IAICustomizationGroupItem[]> {
402
const groups: IAICustomizationGroupItem[] = [];
403
404
// Check cache first
405
let cached = this.cache.get(promptType);
406
if (!cached) {
407
cached = {};
408
this.cache.set(promptType, cached);
409
}
410
411
// For skills, use findAgentSkills which has the proper names from frontmatter
412
if (promptType === PromptsType.skill) {
413
if (!cached.skills) {
414
const skills = await this.promptsService.findAgentSkills(CancellationToken.None);
415
cached.skills = skills || [];
416
this.totalItemCount += cached.skills.length;
417
this.onItemCountChanged(this.totalItemCount);
418
}
419
420
const workspaceSkills = cached.skills.filter(s => s.storage === PromptsStorage.local);
421
const userSkills = cached.skills.filter(s => s.storage === PromptsStorage.user);
422
const extensionSkills = cached.skills.filter(s => s.storage === PromptsStorage.extension);
423
const builtinSkills = cached.skills.filter(s => s.storage === BUILTIN_STORAGE);
424
425
if (workspaceSkills.length > 0) {
426
groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceSkills.length));
427
}
428
if (userSkills.length > 0) {
429
groups.push(this.createGroupItem(promptType, PromptsStorage.user, userSkills.length));
430
}
431
if (extensionSkills.length > 0) {
432
groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionSkills.length));
433
}
434
if (builtinSkills.length > 0) {
435
groups.push(this.createGroupItem(promptType, BUILTIN_STORAGE, builtinSkills.length));
436
}
437
438
return groups;
439
}
440
441
// For other types, fetch once and cache grouped by storage
442
if (!cached.files) {
443
const allItems: IPromptPath[] = [...await this.promptsService.listPromptFiles(promptType, CancellationToken.None)];
444
445
// For instructions, also include agent instructions (AGENTS.md, copilot-instructions.md, CLAUDE.md, etc.)
446
if (promptType === PromptsType.instructions) {
447
const existingUris = new ResourceSet(allItems.map(item => item.uri));
448
const agentInstructions = await this.promptsService.listAgentInstructions(CancellationToken.None);
449
for (const file of agentInstructions) {
450
if (!existingUris.has(file.uri)) {
451
allItems.push({ uri: file.uri, storage: PromptsStorage.local, type: PromptsType.instructions });
452
}
453
}
454
}
455
456
const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local);
457
const userItems = allItems.filter(item => item.storage === PromptsStorage.user);
458
const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension);
459
const builtinItems = allItems.filter(item => item.storage === BUILTIN_STORAGE);
460
461
cached.files = new Map<string, readonly IPromptPath[]>([
462
[PromptsStorage.local, workspaceItems],
463
[PromptsStorage.user, userItems],
464
[PromptsStorage.extension, extensionItems],
465
[BUILTIN_STORAGE, builtinItems],
466
]);
467
468
const itemCount = allItems.length;
469
this.totalItemCount += itemCount;
470
this.onItemCountChanged(this.totalItemCount);
471
}
472
473
const workspaceItems = cached.files!.get(PromptsStorage.local) || [];
474
const userItems = cached.files!.get(PromptsStorage.user) || [];
475
const extensionItems = cached.files!.get(PromptsStorage.extension) || [];
476
const builtinItems = cached.files!.get(BUILTIN_STORAGE) || [];
477
478
if (workspaceItems.length > 0) {
479
groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceItems.length));
480
}
481
if (userItems.length > 0) {
482
groups.push(this.createGroupItem(promptType, PromptsStorage.user, userItems.length));
483
}
484
if (extensionItems.length > 0) {
485
groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionItems.length));
486
}
487
if (builtinItems.length > 0) {
488
groups.push(this.createGroupItem(promptType, BUILTIN_STORAGE, builtinItems.length));
489
}
490
491
return groups;
492
}
493
494
/**
495
* Creates a group item with consistent structure.
496
*/
497
private createGroupItem(promptType: PromptsType, storage: AICustomizationPromptsStorage, count: number): IAICustomizationGroupItem {
498
const storageLabels: Record<string, string> = {
499
[PromptsStorage.local]: localize('workspaceWithCount', "Workspace ({0})", count),
500
[PromptsStorage.user]: localize('userWithCount', "User ({0})", count),
501
[PromptsStorage.extension]: localize('extensionsWithCount', "Extensions ({0})", count),
502
[PromptsStorage.plugin]: localize('pluginsWithCount', "Plugins ({0})", count),
503
[BUILTIN_STORAGE]: localize('builtinWithCount', "Built-in ({0})", count),
504
};
505
506
const storageIcons: Record<string, ThemeIcon> = {
507
[PromptsStorage.local]: workspaceIcon,
508
[PromptsStorage.user]: userIcon,
509
[PromptsStorage.extension]: extensionIcon,
510
[PromptsStorage.plugin]: pluginIcon,
511
[BUILTIN_STORAGE]: builtinIcon,
512
};
513
514
const storageSuffixes: Record<string, string> = {
515
[PromptsStorage.local]: 'workspace',
516
[PromptsStorage.user]: 'user',
517
[PromptsStorage.extension]: 'extensions',
518
[PromptsStorage.plugin]: 'plugins',
519
[BUILTIN_STORAGE]: 'builtin',
520
};
521
522
return {
523
type: 'group',
524
id: `group-${promptType}-${storageSuffixes[storage]}`,
525
label: storageLabels[storage],
526
storage,
527
promptType,
528
icon: storageIcons[storage],
529
};
530
}
531
532
/**
533
* Returns files for a specific storage/type combination from cache.
534
* getStorageGroups must be called first to populate the cache.
535
*/
536
private async getFilesForStorageAndType(storage: AICustomizationPromptsStorage, promptType: PromptsType): Promise<IAICustomizationFileItem[]> {
537
const cached = this.cache.get(promptType);
538
const disabledUris = this.promptsService.getDisabledPromptFiles(promptType);
539
540
// For skills, use the cached skills data and merge in disabled skills
541
if (promptType === PromptsType.skill) {
542
const skills = cached?.skills || [];
543
const filtered = skills.filter(skill => skill.storage === storage);
544
const seenUris = new Set<string>();
545
const result: IAICustomizationFileItem[] = filtered
546
.map(skill => {
547
seenUris.add(skill.uri.toString());
548
// Use skill name from frontmatter, or fallback to parent folder name
549
const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri);
550
return {
551
type: 'file' as const,
552
id: skill.uri.toString(),
553
uri: skill.uri,
554
name: skillName,
555
description: skill.description,
556
storage: skill.storage,
557
promptType,
558
disabled: disabledUris.has(skill.uri),
559
};
560
});
561
562
// Include disabled skills not already in the enabled list
563
if (disabledUris.size > 0) {
564
const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None);
565
for (const file of allSkillFiles) {
566
if (file.storage === storage && !seenUris.has(file.uri.toString()) && disabledUris.has(file.uri)) {
567
result.push({
568
type: 'file' as const,
569
id: file.uri.toString(),
570
uri: file.uri,
571
name: file.name || basename(dirname(file.uri)) || basename(file.uri),
572
description: file.description,
573
storage: file.storage,
574
promptType,
575
disabled: true,
576
});
577
}
578
}
579
}
580
581
return result;
582
}
583
584
// Use cached files data (already fetched in getStorageGroups)
585
const items = [...(cached?.files?.get(storage) || [])];
586
return items.map(item => ({
587
type: 'file' as const,
588
id: item.uri.toString(),
589
uri: item.uri,
590
name: item.name || basename(item.uri),
591
description: item.description,
592
storage: item.storage,
593
promptType,
594
disabled: disabledUris.has(item.uri),
595
}));
596
}
597
}
598
599
//#endregion
600
601
//#region Unified View Pane
602
603
/**
604
* Unified view pane for all AI Customization items (agents, skills, instructions, prompts).
605
*/
606
export class AICustomizationViewPane extends ViewPane {
607
static readonly ID = 'aiCustomization.view';
608
609
private tree: WorkbenchAsyncDataTree<RootElement, AICustomizationTreeItem, FuzzyScore> | undefined;
610
private dataSource: UnifiedAICustomizationDataSource | undefined;
611
private treeContainer: HTMLElement | undefined;
612
private readonly treeDisposables = this._register(new DisposableStore());
613
614
// Context keys for controlling menu visibility and welcome content
615
private readonly isEmptyContextKey: IContextKey<boolean>;
616
private readonly itemTypeContextKey: IContextKey<string>;
617
private readonly itemDisabledContextKey: IContextKey<boolean>;
618
private readonly itemStorageContextKey: IContextKey<string>;
619
620
constructor(
621
options: IViewPaneOptions,
622
@IKeybindingService keybindingService: IKeybindingService,
623
@IContextMenuService contextMenuService: IContextMenuService,
624
@IConfigurationService configurationService: IConfigurationService,
625
@IContextKeyService contextKeyService: IContextKeyService,
626
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
627
@IInstantiationService instantiationService: IInstantiationService,
628
@IOpenerService openerService: IOpenerService,
629
@IThemeService themeService: IThemeService,
630
@IHoverService hoverService: IHoverService,
631
@IPromptsService private readonly promptsService: IPromptsService,
632
@IEditorService private readonly editorService: IEditorService,
633
@IMenuService private readonly menuService: IMenuService,
634
@ILogService private readonly logService: ILogService,
635
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
636
@IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService,
637
) {
638
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
639
640
// Initialize context keys
641
this.isEmptyContextKey = AICustomizationIsEmptyContextKey.bindTo(contextKeyService);
642
this.itemTypeContextKey = AICustomizationItemTypeContextKey.bindTo(contextKeyService);
643
this.itemDisabledContextKey = AICustomizationItemDisabledContextKey.bindTo(contextKeyService);
644
this.itemStorageContextKey = AICustomizationItemStorageContextKey.bindTo(contextKeyService);
645
646
// Subscribe to prompt service events to refresh tree
647
this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh()));
648
this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh()));
649
650
// Listen to workspace folder changes to refresh tree
651
this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.refresh()));
652
this._register(autorun(reader => {
653
this.workspaceService.activeProjectRoot.read(reader);
654
this.refresh();
655
}));
656
657
}
658
659
protected override renderBody(container: HTMLElement): void {
660
super.renderBody(container);
661
662
container.classList.add('ai-customization-view');
663
this.treeContainer = dom.append(container, dom.$('.tree-container'));
664
665
this.createTree();
666
}
667
668
private createTree(): void {
669
if (!this.treeContainer) {
670
return;
671
}
672
673
// Create data source with callback for tracking item count
674
this.dataSource = new UnifiedAICustomizationDataSource(
675
this.promptsService,
676
this.logService,
677
(count) => this.isEmptyContextKey.set(count === 0),
678
);
679
680
this.tree = this.treeDisposables.add(this.instantiationService.createInstance(
681
WorkbenchAsyncDataTree<RootElement, AICustomizationTreeItem, FuzzyScore>,
682
'AICustomization',
683
this.treeContainer,
684
new AICustomizationTreeDelegate(),
685
[
686
new AICustomizationCategoryRenderer(),
687
new AICustomizationGroupRenderer(),
688
new AICustomizationFileRenderer(this.menuService, this.contextKeyService, this.instantiationService),
689
],
690
this.dataSource,
691
{
692
identityProvider: {
693
getId: (element: AICustomizationTreeItem) => element.id,
694
},
695
accessibilityProvider: {
696
getAriaLabel: (element: AICustomizationTreeItem) => {
697
if (element.type === 'category' || element.type === 'link') {
698
return element.label;
699
}
700
if (element.type === 'group') {
701
return element.label;
702
}
703
// For files, include description and disabled state
704
const nameAndDesc = element.description
705
? localize('fileAriaLabel', "{0}, {1}", element.name, element.description)
706
: element.name;
707
return element.disabled
708
? localize('fileAriaLabelDisabled', "{0}, disabled", nameAndDesc)
709
: nameAndDesc;
710
},
711
getWidgetAriaLabel: () => localize('aiCustomizationTree', "Chat Customization Items"),
712
},
713
keyboardNavigationLabelProvider: {
714
getKeyboardNavigationLabel: (element: AICustomizationTreeItem) => {
715
if (element.type === 'file') {
716
return element.name;
717
}
718
return element.label;
719
},
720
},
721
}
722
));
723
724
// Handle double-click to open file or navigate to section
725
this.treeDisposables.add(this.tree.onDidOpen(async e => {
726
if (e.element && e.element.type === 'file') {
727
this.editorService.openEditor({
728
resource: e.element.uri,
729
});
730
} else if (e.element && e.element.type === 'link') {
731
const input = AICustomizationManagementEditorInput.getOrCreate();
732
const editor = await this.editorService.openEditor(input, { pinned: true });
733
if (editor instanceof AICustomizationManagementEditor) {
734
editor.selectSectionById(e.element.section);
735
}
736
}
737
}));
738
739
// Handle context menu
740
this.treeDisposables.add(this.tree.onContextMenu(e => this.onContextMenu(e)));
741
742
// Initial load and auto-expand category nodes
743
void this.tree.setInput(ROOT_ELEMENT).then(() => this.autoExpandCategories());
744
}
745
746
private async autoExpandCategories(): Promise<void> {
747
if (!this.tree) {
748
return;
749
}
750
// Auto-expand all category nodes to show storage groups
751
const rootNode = this.tree.getNode(ROOT_ELEMENT);
752
for (const child of rootNode.children) {
753
if (child.element !== ROOT_ELEMENT) {
754
await this.tree.expand(child.element);
755
}
756
}
757
}
758
759
protected override layoutBody(height: number, width: number): void {
760
super.layoutBody(height, width);
761
this.tree?.layout(height, width);
762
}
763
764
public refresh(): void {
765
// Clear the cache before refreshing
766
this.dataSource?.clearCache();
767
this.isEmptyContextKey.set(true); // Reset until we know the count
768
void this.tree?.setInput(ROOT_ELEMENT).then(() => this.autoExpandCategories());
769
}
770
771
public collapseAll(): void {
772
this.tree?.collapseAll();
773
}
774
775
public expandAll(): void {
776
this.tree?.expandAll();
777
}
778
779
private onContextMenu(e: ITreeContextMenuEvent<AICustomizationTreeItem | null>): void {
780
// Only show context menu for file items
781
if (!e.element || e.element.type !== 'file') {
782
return;
783
}
784
785
const element = e.element;
786
787
// Set context keys for the item so menu items can use `when` clauses
788
this.itemTypeContextKey.set(element.promptType);
789
this.itemDisabledContextKey.set(element.disabled);
790
this.itemStorageContextKey.set(element.storage);
791
792
// Get menu actions from the menu service
793
const context = {
794
uri: element.uri.toString(),
795
name: element.name,
796
promptType: element.promptType,
797
disabled: element.disabled,
798
};
799
const menu = this.menuService.getMenuActions(AICustomizationItemMenuId, this.contextKeyService, { arg: context, shouldForwardArgs: true });
800
const { secondary } = getContextMenuActions(menu, 'inline');
801
802
// Show the context menu
803
if (secondary.length > 0) {
804
this.contextMenuService.showContextMenu({
805
getAnchor: () => e.anchor,
806
getActions: () => secondary,
807
getActionsContext: () => context,
808
onHide: () => {
809
// Clear the context keys when menu closes
810
this.itemTypeContextKey.reset();
811
this.itemDisabledContextKey.reset();
812
this.itemStorageContextKey.reset();
813
},
814
});
815
}
816
}
817
}
818
819
//#endregion
820
821