Path: blob/main/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.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 { DragAndDropObserver } from '../../../../base/browser/dom.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';9import { URI } from '../../../../base/common/uri.js';10import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';11import { Emitter } from '../../../../base/common/event.js';12import { renderIcon, renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';13import { localize } from '../../../../nls.js';14import { ThemeIcon } from '../../../../base/common/themables.js';15import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js';16import { IOpenerService } from '../../../../platform/opener/common/opener.js';17import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js';18import { IChatImageCarouselService } from '../../../../workbench/contrib/chat/browser/chatImageCarouselService.js';19import { coerceImageBuffer } from '../../../../workbench/contrib/chat/common/chatImageExtraction.js';2021import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';22import { ITextModelService } from '../../../../editor/common/services/resolverService.js';23import { FileKind, IFileService } from '../../../../platform/files/common/files.js';24import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';25import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';26import { ILabelService } from '../../../../platform/label/common/label.js';27import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';28import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';29import { IModelService } from '../../../../editor/common/services/model.js';30import { ILanguageService } from '../../../../editor/common/languages/language.js';31import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';32import { basename } from '../../../../base/common/resources.js';33import { Schemas } from '../../../../base/common/network.js';34import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js';3536import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';37import { isLocation } from '../../../../editor/common/languages.js';38import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js';39import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js';40import { CodeDataTransfers, containsDragType, extractEditorsDropData, getPathForFile } from '../../../../platform/dnd/browser/dnd.js';41import { DataTransfers } from '../../../../base/browser/dnd.js';42import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js';4344/**45* Manages context attachments for the sessions new-chat widget.46*47* Supports:48* - File picker via quick access ("Files and Open Folders...")49* - Image from Clipboard50* - Drag and drop files51* - Paste images from clipboard (Ctrl/Cmd+V)52*/53export class NewChatContextAttachments extends Disposable {5455private readonly _attachedContext: IChatRequestVariableEntry[] = [];56private _container: HTMLElement | undefined;57private readonly _renderDisposables = this._register(new DisposableStore());5859private readonly _onDidChangeContext = this._register(new Emitter<void>());60readonly onDidChangeContext = this._onDidChangeContext.event;6162get attachments(): readonly IChatRequestVariableEntry[] {63return this._attachedContext;64}6566setAttachments(entries: readonly IChatRequestVariableEntry[]): void {67this._attachedContext.length = 0;68this._attachedContext.push(...entries);69this._updateRendering();70this._onDidChangeContext.fire();71}7273private readonly _resourceLabels: ResourceLabels;7475constructor(76@IQuickInputService private readonly quickInputService: IQuickInputService,77@ITextModelService private readonly textModelService: ITextModelService,78@IFileService private readonly fileService: IFileService,79@IClipboardService private readonly clipboardService: IClipboardService,80@IFileDialogService private readonly fileDialogService: IFileDialogService,81@ILabelService private readonly labelService: ILabelService,82@ISearchService private readonly searchService: ISearchService,83@IConfigurationService private readonly configurationService: IConfigurationService,84@IOpenerService private readonly openerService: IOpenerService,85@IInstantiationService private readonly instantiationService: IInstantiationService,86@IModelService private readonly modelService: IModelService,87@ILanguageService private readonly languageService: ILanguageService,88@IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService,89) {90super();91this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));92}9394// --- Rendering ---9596renderAttachedContext(container: HTMLElement): void {97this._container = container;98this._updateRendering();99}100101private _updateRendering(): void {102if (!this._container) {103return;104}105106this._renderDisposables.clear();107this._resourceLabels.clear();108dom.clearNode(this._container);109110if (this._attachedContext.length === 0) {111this._container.style.display = 'none';112return;113}114115this._container.style.display = '';116this._container.classList.add('show-file-icons');117118for (const entry of this._attachedContext) {119const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill'));120pill.tabIndex = 0;121pill.role = 'button';122const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined;123if (entry.kind === 'image') {124dom.append(pill, renderIcon(Codicon.fileMedia));125dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name));126} else {127const label = this._resourceLabels.create(pill, { supportIcons: true });128this._renderDisposables.add(label);129if (resource) {130label.setFile(resource, {131fileKind: entry.kind === 'directory' ? FileKind.FOLDER : FileKind.FILE,132hidePath: true,133});134} else {135label.setLabel(entry.name);136}137}138139// Click to open the resource or image140const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined;141if (imageData) {142pill.style.cursor = 'pointer';143this._renderDisposables.add(registerOpenEditorListeners(pill, async () => {144if (this.configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled)) {145const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name });146await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData);147} else if (resource) {148await this.openerService.open(resource, { fromUserGesture: true });149}150}));151} else if (resource) {152pill.style.cursor = 'pointer';153this._renderDisposables.add(registerOpenEditorListeners(pill, async () => {154await this.openerService.open(resource, { fromUserGesture: true });155}));156}157158const removeButton = dom.append(pill, dom.$('.sessions-chat-attachment-remove'));159removeButton.title = localize('removeAttachment', "Remove");160removeButton.tabIndex = -1;161dom.append(removeButton, renderIcon(Codicon.close));162this._renderDisposables.add(dom.addDisposableListener(removeButton, dom.EventType.CLICK, (e) => {163e.stopPropagation();164this._removeAttachment(entry.id);165}));166}167}168169// --- Drag and drop ---170171registerDropTarget(dndContainer: HTMLElement): void {172const overlay = dom.append(dndContainer, dom.$('.sessions-chat-dnd-overlay'));173let overlayText: HTMLElement | undefined;174175const isDropSupported = (e: DragEvent): boolean => {176return containsDragType(e, DataTransfers.FILES, CodeDataTransfers.EDITORS, CodeDataTransfers.FILES, DataTransfers.RESOURCES, DataTransfers.INTERNAL_URI_LIST);177};178179const showOverlay = () => {180overlay.classList.add('visible');181if (!overlayText) {182const label = localize('attachAsContext', "Attach as Context");183const iconAndTextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${label}`);184const htmlElements = iconAndTextElements.map(element => {185if (typeof element === 'string') {186return dom.$('span.overlay-text', undefined, element);187}188return element;189});190overlayText = dom.$('span.attach-context-overlay-text', undefined, ...htmlElements);191overlay.appendChild(overlayText);192}193};194195const hideOverlay = () => {196overlay.classList.remove('visible');197overlayText?.remove();198overlayText = undefined;199};200201this._register(new DragAndDropObserver(dndContainer, {202onDragOver: (e) => {203if (isDropSupported(e)) {204e.preventDefault();205e.stopPropagation();206if (e.dataTransfer) {207e.dataTransfer.dropEffect = 'copy';208}209showOverlay();210}211},212onDragLeave: () => {213hideOverlay();214},215onDrop: async (e) => {216e.preventDefault();217e.stopPropagation();218hideOverlay();219220// Extract editor data from VS Code internal drags (e.g., explorer view)221const editorDropData = extractEditorsDropData(e);222if (editorDropData.length > 0) {223for (const editor of editorDropData) {224if (editor.resource) {225await this._attachFileUri(editor.resource, basename(editor.resource));226}227}228return;229}230231// Fallback: try native file items232const items = e.dataTransfer?.items;233if (items) {234for (const item of Array.from(items)) {235if (item.kind === 'file') {236const file = item.getAsFile();237if (!file) {238continue;239}240const filePath = getPathForFile(file);241if (!filePath) {242continue;243}244const uri = URI.file(filePath);245await this._attachFileUri(uri, file.name);246}247}248}249},250}));251}252253// --- Paste ---254255registerPasteHandler(element: HTMLElement): void {256const supportedMimeTypes = [257'image/png',258'image/jpeg',259'image/jpg',260'image/bmp',261'image/gif',262'image/tiff'263];264265this._register(dom.addDisposableListener(element, dom.EventType.PASTE, async (e: ClipboardEvent) => {266const items = e.clipboardData?.items;267if (!items) {268return;269}270271// Check synchronously for image data before any async work272// so preventDefault stops the editor from inserting text.273let imageFile: File | undefined;274for (const item of Array.from(items)) {275if (!item.type.startsWith('image/') || !supportedMimeTypes.includes(item.type)) {276continue;277}278const file = item.getAsFile();279if (file) {280imageFile = file;281break;282}283}284285if (!imageFile) {286return;287}288289e.preventDefault();290e.stopPropagation();291292const arrayBuffer = await imageFile.arrayBuffer();293const data = new Uint8Array(arrayBuffer);294if (!isImage(data)) {295return;296}297298const resizedData = await resizeImage(data, imageFile.type);299const displayName = this._getUniqueImageName();300301this._addAttachments({302id: await imageToHash(resizedData),303name: displayName,304fullName: displayName,305value: resizedData,306kind: 'image',307});308}, true));309}310311// --- Picker ---312313showPicker(folderUri?: URI): void {314const picker = this.quickInputService.createQuickPick<IQuickPickItem>({ useSeparators: true });315const disposables = new DisposableStore();316picker.placeholder = localize('chatContext.attach.placeholder', "Attach as context...");317picker.matchOnDescription = true;318picker.sortByLabel = false;319320const staticPicks: (IQuickPickItem | IQuickPickSeparator)[] = [321{322label: localize('files', "Files..."),323iconClass: ThemeIcon.asClassName(Codicon.file),324id: 'sessions.filesAndFolders',325},326{327label: localize('imageFromClipboard', "Image from Clipboard"),328iconClass: ThemeIcon.asClassName(Codicon.fileMedia),329id: 'sessions.imageFromClipboard',330},331];332333picker.items = staticPicks;334picker.show();335336if (folderUri) {337let searchCts: CancellationTokenSource | undefined;338let debounceTimer: ReturnType<typeof setTimeout> | undefined;339340const runSearch = (filePattern?: string) => {341searchCts?.dispose(true);342searchCts = new CancellationTokenSource();343const token = searchCts.token;344345picker.busy = true;346this._collectFilePicks(folderUri, filePattern, token).then(filePicks => {347if (token.isCancellationRequested) {348return;349}350picker.busy = false;351if (filePicks.length > 0) {352picker.items = [353...staticPicks,354{ type: 'separator', label: basename(folderUri) },355...filePicks,356];357} else {358picker.items = staticPicks;359}360});361};362363// Initial search (no filter)364runSearch();365366// Re-search on user input with debounce367disposables.add(picker.onDidChangeValue(value => {368if (debounceTimer) {369clearTimeout(debounceTimer);370}371debounceTimer = setTimeout(() => runSearch(value || undefined), 200);372}));373374disposables.add({ dispose: () => { searchCts?.dispose(true); if (debounceTimer) { clearTimeout(debounceTimer); } } });375}376377disposables.add(picker.onDidAccept(async () => {378const [selected] = picker.selectedItems;379if (!selected) {380picker.hide();381return;382}383384picker.hide();385386if (selected.id === 'sessions.filesAndFolders') {387await this._handleFileDialog();388} else if (selected.id === 'sessions.imageFromClipboard') {389await this._handleClipboardImage();390} else if (selected.id) {391await this._attachFileUri(URI.parse(selected.id), selected.label);392}393}));394395disposables.add(picker.onDidHide(() => {396picker.dispose();397disposables.dispose();398}));399}400401private async _collectFilePicks(rootUri: URI, filePattern?: string, token?: CancellationToken): Promise<IQuickPickItem[]> {402const maxFiles = 200;403404// For local file:// URIs, use the search service which respects .gitignore and excludes405if (rootUri.scheme === Schemas.file || rootUri.scheme === Schemas.vscodeRemote) {406return this._collectFilePicksViaSearch(rootUri, maxFiles, filePattern, token);407}408409// For virtual filesystems (e.g. github-remote-file://), walk the tree via IFileService410return this._collectFilePicksViaFileService(rootUri, maxFiles, filePattern);411}412413private async _collectFilePicksViaSearch(rootUri: URI, maxFiles: number, filePattern?: string, token?: CancellationToken): Promise<IQuickPickItem[]> {414const excludePattern = getExcludes(this.configurationService.getValue<ISearchConfiguration>({ resource: rootUri }));415416try {417const searchResult = await this.searchService.fileSearch({418folderQueries: [{419folder: rootUri,420disregardIgnoreFiles: false,421}],422type: QueryType.File,423filePattern: filePattern || '',424excludePattern,425sortByScore: true,426maxResults: maxFiles,427}, token);428429return searchResult.results.map(result => ({430label: basename(result.resource),431description: this.labelService.getUriLabel(result.resource, { relative: true }),432iconClasses: getIconClasses(this.modelService, this.languageService, result.resource, FileKind.FILE),433id: result.resource.toString(),434} satisfies IQuickPickItem));435} catch {436return [];437}438}439440private async _collectFilePicksViaFileService(rootUri: URI, maxFiles: number, filePattern?: string): Promise<IQuickPickItem[]> {441const picks: IQuickPickItem[] = [];442const patternLower = filePattern?.toLowerCase();443const maxDepth = 10;444445const collect = async (uri: URI, depth: number): Promise<void> => {446if (picks.length >= maxFiles || depth > maxDepth) {447return;448}449450try {451const stat = await this.fileService.resolve(uri);452if (!stat.children) {453return;454}455456const children = stat.children.slice().sort((a, b) => {457if (a.isDirectory !== b.isDirectory) {458return a.isDirectory ? -1 : 1;459}460return a.name.localeCompare(b.name);461});462463for (const child of children) {464if (picks.length >= maxFiles) {465break;466}467if (child.isDirectory) {468await collect(child.resource, depth + 1);469} else {470if (patternLower && !child.name.toLowerCase().includes(patternLower)) {471continue;472}473picks.push({474label: child.name,475description: this.labelService.getUriLabel(child.resource, { relative: true }),476iconClasses: getIconClasses(this.modelService, this.languageService, child.resource, FileKind.FILE),477id: child.resource.toString(),478});479}480}481} catch {482// ignore errors for individual directories483}484};485486await collect(rootUri, 0);487return picks;488}489490private async _handleFileDialog(): Promise<void> {491const selected = await this.fileDialogService.showOpenDialog({492canSelectFiles: true,493canSelectFolders: true,494canSelectMany: true,495title: localize('selectFilesOrFolders', "Select Files or Folders"),496});497if (!selected) {498return;499}500501for (const uri of selected) {502await this._attachFileUri(uri, basename(uri));503}504}505506private async _attachFileUri(uri: URI, name: string): Promise<void> {507let stat;508try {509stat = await this.fileService.stat(uri);510} catch {511return;512}513514if (stat.isDirectory) {515this._addAttachments({516kind: 'directory',517id: uri.toString(),518value: uri,519name,520});521return;522}523524if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(uri.path)) {525const readFile = await this.fileService.readFile(uri);526const resizedImage = await resizeImage(readFile.value.buffer);527this._addAttachments({528id: uri.toString(),529name,530fullName: name,531value: resizedImage,532kind: 'image',533references: [{ reference: uri, kind: 'reference' }]534});535} else {536let omittedState = OmittedState.NotOmitted;537try {538const ref = await this.textModelService.createModelReference(uri);539ref.dispose();540} catch {541omittedState = OmittedState.Full;542}543544this._addAttachments({545kind: 'file',546id: uri.toString(),547value: uri,548name,549omittedState,550});551}552}553554private async _handleClipboardImage(): Promise<void> {555const imageData = await this.clipboardService.readImage();556if (!isImage(imageData)) {557return;558}559560const displayName = this._getUniqueImageName();561562this._addAttachments({563id: await imageToHash(imageData),564name: displayName,565fullName: displayName,566value: imageData,567kind: 'image',568});569}570571// --- State management ---572573private _getUniqueImageName(): string {574const baseName = localize('pastedImage', "Pasted Image");575let name = baseName;576for (let i = 2; this._attachedContext.some(a => a.name === name); i++) {577name = `${baseName} ${i}`;578}579return name;580}581582addAttachments(...entries: IChatRequestVariableEntry[]): void {583this._addAttachments(...entries);584}585586private _addAttachments(...entries: IChatRequestVariableEntry[]): void {587for (const entry of entries) {588if (!this._attachedContext.some(e => e.id === entry.id)) {589this._attachedContext.push(entry);590}591}592this._updateRendering();593this._onDidChangeContext.fire();594}595596private _removeAttachment(id: string): void {597const index = this._attachedContext.findIndex(e => e.id === id);598if (index >= 0) {599this._attachedContext.splice(index, 1);600this._updateRendering();601this._onDidChangeContext.fire();602}603}604605clear(): void {606this._attachedContext.length = 0;607this._updateRendering();608this._onDidChangeContext.fire();609}610}611612613