Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts
5262 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 { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js';
8
import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js';
9
import { Button } from '../../../../../base/browser/ui/button/button.js';
10
import { Codicon } from '../../../../../base/common/codicons.js';
11
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
12
import { ThemeIcon } from '../../../../../base/common/themables.js';
13
import { KeyCode } from '../../../../../base/common/keyCodes.js';
14
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
15
import { Schemas } from '../../../../../base/common/network.js';
16
import { basename, dirname } from '../../../../../base/common/resources.js';
17
import { URI } from '../../../../../base/common/uri.js';
18
import { isLocation, Location } from '../../../../../editor/common/languages.js';
19
import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js';
20
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
21
import { IModelService } from '../../../../../editor/common/services/model.js';
22
import { localize } from '../../../../../nls.js';
23
import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js';
24
import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js';
25
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
26
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
27
import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';
28
import { FileKind, IFileService } from '../../../../../platform/files/common/files.js';
29
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
30
import { ILabelService } from '../../../../../platform/label/common/label.js';
31
import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js';
32
import { ResourceContextKey } from '../../../../common/contextkeys.js';
33
import { IChatRequestStringVariableEntry, isStringImplicitContextValue } from '../../common/attachments/chatVariableEntries.js';
34
import { IChatWidget } from '../chat.js';
35
import { ChatAttachmentModel } from './chatAttachmentModel.js';
36
import { IChatContextService } from '../contextContrib/chatContextService.js';
37
import { ChatImplicitContext, ChatImplicitContexts } from './chatImplicitContext.js';
38
import { IRange } from '../../../../../editor/common/core/range.js';
39
40
export class ImplicitContextAttachmentWidget extends Disposable {
41
42
private readonly renderDisposables = this._register(new DisposableStore());
43
private renderedCount = 0;
44
45
constructor(
46
private readonly widgetRef: () => IChatWidget | undefined,
47
private readonly isAttachmentAlreadyAttached: (targetUri: URI | undefined, targetRange: IRange | undefined, targetHandle: number | undefined) => boolean,
48
private readonly attachment: ChatImplicitContexts,
49
private readonly resourceLabels: ResourceLabels,
50
private readonly attachmentModel: ChatAttachmentModel,
51
private readonly domNode: HTMLElement,
52
@IContextKeyService private readonly contextKeyService: IContextKeyService,
53
@IContextMenuService private readonly contextMenuService: IContextMenuService,
54
@ILabelService private readonly labelService: ILabelService,
55
@IMenuService private readonly menuService: IMenuService,
56
@IFileService private readonly fileService: IFileService,
57
@ILanguageService private readonly languageService: ILanguageService,
58
@IModelService private readonly modelService: IModelService,
59
@IHoverService private readonly hoverService: IHoverService,
60
@IConfigurationService private readonly configService: IConfigurationService,
61
@IChatContextService private readonly chatContextService: IChatContextService,
62
) {
63
super();
64
65
this.render();
66
}
67
68
private render() {
69
this.renderDisposables.clear();
70
this.renderedCount = 0;
71
72
for (const context of this.attachment.values) {
73
const targetUri: URI | undefined = context.uri;
74
const targetRange = isLocation(context.value) ? context.value.range : undefined;
75
const targetHandle = isStringImplicitContextValue(context.value) ? context.value.handle : undefined;
76
const currentlyAttached = this.isAttachmentAlreadyAttached(targetUri, targetRange, targetHandle);
77
if (!currentlyAttached) {
78
this.renderMainContext(context, context.isSelection);
79
this.renderedCount++;
80
}
81
}
82
}
83
84
get hasRenderedContexts(): boolean {
85
return this.renderedCount > 0;
86
}
87
88
private renderMainContext(context: ChatImplicitContext, isSelection?: boolean) {
89
const contextNode = dom.$('.chat-attached-context-attachment.show-file-icons.implicit');
90
this.domNode.appendChild(contextNode);
91
92
contextNode.classList.toggle('disabled', !context.enabled);
93
const file: URI | undefined = context.uri;
94
const attachmentTypeName = file?.scheme === Schemas.vscodeNotebookCell ? localize('cell.lowercase', "cell") : localize('file.lowercase', "file");
95
96
const isSuggestedEnabled = this.configService.getValue('chat.implicitContext.suggestedContext');
97
98
// Create toggle button BEFORE the label so it appears on the left
99
if (isSuggestedEnabled) {
100
if (!isSelection) {
101
const buttonMsg = context.enabled ? localize('disable', "Disable current {0} context", attachmentTypeName) : '';
102
const toggleButton = this.renderDisposables.add(new Button(contextNode, { supportIcons: true, title: buttonMsg }));
103
toggleButton.icon = context.enabled ? Codicon.x : Codicon.plus;
104
this.renderDisposables.add(toggleButton.onDidClick(async (e) => {
105
e.stopPropagation();
106
e.preventDefault();
107
if (!context.enabled) {
108
await this.convertToRegularAttachment(context);
109
}
110
context.enabled = false;
111
}));
112
} else {
113
const pinButtonMsg = localize('pinSelection', "Pin selection");
114
const pinButton = this.renderDisposables.add(new Button(contextNode, { supportIcons: true, title: pinButtonMsg }));
115
pinButton.icon = Codicon.pinned;
116
this.renderDisposables.add(pinButton.onDidClick(async (e) => {
117
e.stopPropagation();
118
e.preventDefault();
119
await this.pinSelection();
120
}));
121
}
122
123
if (!context.enabled && isSelection) {
124
contextNode.classList.remove('disabled');
125
}
126
127
this.renderDisposables.add(dom.addDisposableListener(contextNode, dom.EventType.CLICK, async (e) => {
128
if (!context.enabled && !isSelection) {
129
await this.convertToRegularAttachment(context);
130
}
131
}));
132
133
this.renderDisposables.add(dom.addDisposableListener(contextNode, dom.EventType.KEY_DOWN, async (e) => {
134
const event = new StandardKeyboardEvent(e);
135
if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {
136
if (!context.enabled && !isSelection) {
137
e.preventDefault();
138
e.stopPropagation();
139
await this.convertToRegularAttachment(context);
140
}
141
}
142
}));
143
} else {
144
const buttonMsg = context.enabled ? localize('disable', "Disable current {0} context", attachmentTypeName) : localize('enable', "Enable current {0} context", attachmentTypeName);
145
const toggleButton = this.renderDisposables.add(new Button(contextNode, { supportIcons: true, title: buttonMsg }));
146
toggleButton.icon = context.enabled ? Codicon.eye : Codicon.eyeClosed;
147
this.renderDisposables.add(toggleButton.onDidClick((e) => {
148
e.stopPropagation(); // prevent it from triggering the click handler on the parent immediately after rerendering
149
context.enabled = !context.enabled;
150
}));
151
}
152
153
const label = this.resourceLabels.create(contextNode, { supportIcons: true });
154
155
let title: string | undefined;
156
let markdownTooltip: IMarkdownString | undefined;
157
if (isStringImplicitContextValue(context.value)) {
158
markdownTooltip = context.value.tooltip;
159
title = this.renderString(label, context.name, context.icon, context.value.resourceUri, markdownTooltip, localize('openFile', "Current file context"));
160
} else {
161
title = this.renderResource(context.value, context.isSelection, context.enabled, label);
162
}
163
164
if (markdownTooltip || title) {
165
this.renderDisposables.add(this.hoverService.setupDelayedHover(contextNode, {
166
content: markdownTooltip! ?? title!,
167
appearance: { showPointer: true },
168
}));
169
}
170
171
// Context menu
172
const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(contextNode));
173
174
const resourceContextKey = this.renderDisposables.add(new ResourceContextKey(scopedContextKeyService, this.fileService, this.languageService, this.modelService));
175
resourceContextKey.set(file);
176
177
this.renderDisposables.add(dom.addDisposableListener(contextNode, dom.EventType.CONTEXT_MENU, async domEvent => {
178
const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent);
179
dom.EventHelper.stop(domEvent, true);
180
181
this.contextMenuService.showContextMenu({
182
contextKeyService: scopedContextKeyService,
183
getAnchor: () => event,
184
getActions: () => {
185
const menu = this.menuService.getMenuActions(MenuId.ChatInputResourceAttachmentContext, scopedContextKeyService, { arg: file });
186
return getFlatContextMenuActions(menu);
187
},
188
});
189
}));
190
}
191
192
private renderString(resourceLabel: IResourceLabel, name: string, icon: ThemeIcon | undefined, resourceUri: URI | undefined, markdownTooltip: IMarkdownString | undefined, defaultTitle: string): string | undefined {
193
// Don't set title if we have a markdown tooltip - the hover service will handle it
194
const title = markdownTooltip ? undefined : defaultTitle;
195
196
// Derive icon classes from resourceUri for file/folder icons
197
if (icon && (ThemeIcon.isFile(icon) || ThemeIcon.isFolder(icon)) && resourceUri) {
198
const fileKind = ThemeIcon.isFolder(icon) ? FileKind.FOLDER : FileKind.FILE;
199
const iconClasses = getIconClasses(this.modelService, this.languageService, resourceUri, fileKind);
200
resourceLabel.setLabel(name, undefined, { extraClasses: iconClasses, title });
201
} else {
202
resourceLabel.setLabel(name, undefined, { iconPath: icon, title });
203
}
204
return title;
205
}
206
207
private renderResource(attachmentValue: Location | URI | undefined, isSelection: boolean, enabled: boolean, label: IResourceLabel): string {
208
const file = URI.isUri(attachmentValue) ? attachmentValue : attachmentValue!.uri;
209
const range = URI.isUri(attachmentValue) || !isSelection ? undefined : attachmentValue!.range;
210
211
const attachmentTypeName = file.scheme === Schemas.vscodeNotebookCell ? localize('cell.lowercase', "cell") : localize('file.lowercase', "file");
212
213
const fileBasename = basename(file);
214
const fileDirname = dirname(file);
215
const friendlyName = `${fileBasename} ${fileDirname}`;
216
const ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached {0}, {1}, line {2} to line {3}", attachmentTypeName, friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached {0}, {1}", attachmentTypeName, friendlyName);
217
218
const uriLabel = this.labelService.getUriLabel(file, { relative: true });
219
const currentFile = localize('openEditor', "Current {0} context", attachmentTypeName);
220
const inactive = localize('enableHint', "Enable current {0} context", attachmentTypeName);
221
const currentFileHint = enabled || isSelection ? currentFile : inactive;
222
const title = `${currentFileHint}\n${uriLabel}`;
223
224
label.setFile(file, {
225
fileKind: FileKind.FILE,
226
hidePath: true,
227
range,
228
title
229
});
230
this.domNode.ariaLabel = ariaLabel;
231
this.domNode.tabIndex = 0;
232
233
return title;
234
}
235
236
private async convertToRegularAttachment(attachment: ChatImplicitContext): Promise<void> {
237
if (!attachment.value) {
238
return;
239
}
240
if (isStringImplicitContextValue(attachment.value)) {
241
if (attachment.value.value === undefined) {
242
await this.chatContextService.resolveChatContext(attachment.value);
243
}
244
const context: IChatRequestStringVariableEntry = {
245
kind: 'string',
246
value: attachment.value.value,
247
id: attachment.id,
248
name: attachment.name,
249
icon: attachment.value.icon,
250
modelDescription: attachment.modelDescription,
251
uri: attachment.value.uri,
252
resourceUri: attachment.value.resourceUri,
253
tooltip: attachment.value.tooltip,
254
commandId: attachment.value.commandId,
255
handle: attachment.value.handle
256
};
257
this.attachmentModel.addContext(context);
258
} else {
259
const file = URI.isUri(attachment.value) ? attachment.value : attachment.value.uri;
260
if (file.scheme === Schemas.vscodeNotebookCell && isLocation(attachment.value)) {
261
this.attachmentModel.addFile(file, attachment.value.range);
262
} else {
263
this.attachmentModel.addFile(file);
264
}
265
}
266
this.widgetRef()?.focusInput();
267
}
268
269
private async pinSelection(): Promise<void> {
270
for (const attachment of this.attachment.values) {
271
if (!attachment.value || !attachment.isSelection) {
272
continue;
273
}
274
275
if (!URI.isUri(attachment.value) && !isStringImplicitContextValue(attachment.value)) {
276
const location = attachment.value;
277
this.attachmentModel.addFile(location.uri, location.range);
278
}
279
}
280
this.widgetRef()?.focusInput();
281
}
282
}
283
284