Skip to content

Commit faa525b

Browse files
authored
Improve chat drag and drop functionality (microsoft#234122)
chat drag and drop improvements
1 parent 16155ab commit faa525b

File tree

6 files changed

+190
-75
lines changed

6 files changed

+190
-75
lines changed

src/vs/workbench/contrib/chat/browser/chatDragAndDrop.ts

Lines changed: 170 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,19 @@ import { $, DragAndDropObserver } from '../../../../base/browser/dom.js';
88
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
99
import { coalesce } from '../../../../base/common/arrays.js';
1010
import { Codicon } from '../../../../base/common/codicons.js';
11+
import { IDisposable } from '../../../../base/common/lifecycle.js';
1112
import { Mimes } from '../../../../base/common/mime.js';
12-
import { basename } from '../../../../base/common/resources.js';
13+
import { basename, joinPath } from '../../../../base/common/resources.js';
1314
import { URI } from '../../../../base/common/uri.js';
1415
import { localize } from '../../../../nls.js';
1516
import { containsDragType, extractEditorsDropData, IDraggedResourceEditorInput } from '../../../../platform/dnd/browser/dnd.js';
16-
import { IFileService } from '../../../../platform/files/common/files.js';
17+
import { FileType, IFileService, IFileSystemProvider } from '../../../../platform/files/common/files.js';
1718
import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js';
1819
import { EditorInput } from '../../../common/editor/editorInput.js';
1920
import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';
2021
import { IChatRequestVariableEntry } from '../common/chatModel.js';
21-
import { ChatInputPart } from './chatInputPart.js';
22-
import { IChatWidgetStyles } from './chatWidget.js';
22+
import { ChatAttachmentModel } from './chatAttachmentModel.js';
23+
import { IChatInputStyles } from './chatInputPart.js';
2324

2425
enum ChatDragAndDropType {
2526
FILE_INTERNAL,
@@ -30,66 +31,101 @@ enum ChatDragAndDropType {
3031

3132
export class ChatDragAndDrop extends Themable {
3233

33-
private readonly overlay: HTMLElement;
34+
private readonly overlays: Map<HTMLElement, { overlay: HTMLElement; disposable: IDisposable }> = new Map();
3435
private overlayText?: HTMLElement;
3536
private overlayTextBackground: string = '';
3637

3738
constructor(
38-
private readonly contianer: HTMLElement,
39-
private readonly inputPart: ChatInputPart,
40-
private readonly styles: IChatWidgetStyles,
39+
protected readonly attachmentModel: ChatAttachmentModel,
40+
private readonly styles: IChatInputStyles,
4141
@IThemeService themeService: IThemeService,
4242
@IExtensionService private readonly extensionService: IExtensionService,
43-
@IFileService private readonly fileService: IFileService
43+
@IFileService protected readonly fileService: IFileService
4444
) {
4545
super(themeService);
4646

47-
// If the mouse enters and leaves the overlay quickly,
48-
// the overlay may stick around due to too many drag enter events
49-
// Make sure the mouse enters only once
50-
let mouseInside = false;
51-
this._register(new DragAndDropObserver(this.contianer, {
52-
onDragEnter: (e) => {
53-
if (!mouseInside) {
54-
mouseInside = true;
55-
this.onDragEnter(e);
56-
}
57-
},
47+
this.updateStyles();
48+
}
49+
50+
addOverlay(target: HTMLElement, overlayContainer: HTMLElement): void {
51+
this.removeOverlay(target);
52+
53+
const { overlay, disposable } = this.createOverlay(target, overlayContainer);
54+
this.overlays.set(target, { overlay, disposable });
55+
}
56+
57+
removeOverlay(target: HTMLElement): void {
58+
if (this.currentActiveTarget === target) {
59+
this.currentActiveTarget = undefined;
60+
}
61+
62+
const existingOverlay = this.overlays.get(target);
63+
if (existingOverlay) {
64+
existingOverlay.overlay.remove();
65+
existingOverlay.disposable.dispose();
66+
this.overlays.delete(target);
67+
}
68+
}
69+
70+
private currentActiveTarget: HTMLElement | undefined = undefined;
71+
private createOverlay(target: HTMLElement, overlayContainer: HTMLElement): { overlay: HTMLElement; disposable: IDisposable } {
72+
const overlay = document.createElement('div');
73+
overlay.classList.add('chat-dnd-overlay');
74+
this.updateOverlayStyles(overlay);
75+
overlayContainer.appendChild(overlay);
76+
77+
const disposable = new DragAndDropObserver(target, {
5878
onDragOver: (e) => {
5979
e.stopPropagation();
80+
e.preventDefault();
81+
82+
if (target === this.currentActiveTarget) {
83+
return;
84+
}
85+
86+
if (this.currentActiveTarget) {
87+
this.setOverlay(this.currentActiveTarget, undefined);
88+
}
89+
90+
this.currentActiveTarget = target;
91+
92+
this.onDragEnter(e, target);
93+
6094
},
6195
onDragLeave: (e) => {
62-
this.onDragLeave(e);
63-
mouseInside = false;
96+
if (target === this.currentActiveTarget) {
97+
this.currentActiveTarget = undefined;
98+
}
99+
100+
this.onDragLeave(e, target);
64101
},
65102
onDrop: (e) => {
66-
this.onDrop(e);
67-
mouseInside = false;
68-
},
69-
}));
103+
e.stopPropagation();
104+
e.preventDefault();
70105

71-
this.overlay = document.createElement('div');
72-
this.overlay.classList.add('chat-dnd-overlay');
73-
this.contianer.appendChild(this.overlay);
106+
if (target !== this.currentActiveTarget) {
107+
return;
108+
}
74109

75-
this.updateStyles();
110+
this.currentActiveTarget = undefined;
111+
this.onDrop(e, target);
112+
},
113+
});
114+
115+
return { overlay, disposable };
76116
}
77117

78-
private onDragEnter(e: DragEvent): void {
118+
private onDragEnter(e: DragEvent, target: HTMLElement): void {
79119
const estimatedDropType = this.guessDropType(e);
80-
if (estimatedDropType !== undefined) {
81-
e.stopPropagation();
82-
e.preventDefault();
83-
}
84-
this.updateDropFeedback(e, estimatedDropType);
120+
this.updateDropFeedback(e, target, estimatedDropType);
85121
}
86122

87-
private onDragLeave(e: DragEvent): void {
88-
this.updateDropFeedback(e, undefined);
123+
private onDragLeave(e: DragEvent, target: HTMLElement): void {
124+
this.updateDropFeedback(e, target, undefined);
89125
}
90126

91-
private onDrop(e: DragEvent): void {
92-
this.updateDropFeedback(e, undefined);
127+
private onDrop(e: DragEvent, target: HTMLElement): void {
128+
this.updateDropFeedback(e, target, undefined);
93129
this.drop(e);
94130
}
95131

@@ -99,19 +135,20 @@ export class ChatDragAndDrop extends Themable {
99135
return;
100136
}
101137

102-
e.stopPropagation();
103-
e.preventDefault();
138+
this.handleDrop(contexts);
139+
}
104140

105-
this.inputPart.attachmentModel.addContext(...contexts);
141+
protected handleDrop(contexts: IChatRequestVariableEntry[]): void {
142+
this.attachmentModel.addContext(...contexts);
106143
}
107144

108-
private updateDropFeedback(e: DragEvent, dropType: ChatDragAndDropType | undefined): void {
145+
private updateDropFeedback(e: DragEvent, target: HTMLElement, dropType: ChatDragAndDropType | undefined): void {
109146
const showOverlay = dropType !== undefined;
110147
if (e.dataTransfer) {
111148
e.dataTransfer.dropEffect = showOverlay ? 'copy' : 'none';
112149
}
113150

114-
this.setOverlay(dropType);
151+
this.setOverlay(target, dropType);
115152
}
116153

117154
private guessDropType(e: DragEvent): ChatDragAndDropType | undefined {
@@ -135,7 +172,7 @@ export class ChatDragAndDrop extends Themable {
135172
return dropType !== undefined;
136173
}
137174

138-
private getDropTypeName(type: ChatDragAndDropType): string {
175+
protected getDropTypeName(type: ChatDragAndDropType): string {
139176
switch (type) {
140177
case ChatDragAndDropType.FILE_INTERNAL: return localize('file', 'File');
141178
case ChatDragAndDropType.FILE_EXTERNAL: return localize('file', 'File');
@@ -208,16 +245,16 @@ export class ChatDragAndDrop extends Themable {
208245
return getResourceAttachContext(editor.resource, stat.isDirectory);
209246
}
210247

211-
private setOverlay(type: ChatDragAndDropType | undefined): void {
248+
private setOverlay(target: HTMLElement, type: ChatDragAndDropType | undefined): void {
212249
// Remove any previous overlay text
213250
this.overlayText?.remove();
214251
this.overlayText = undefined;
215252

253+
const { overlay } = this.overlays.get(target)!;
216254
if (type !== undefined) {
217255
// Render the overlay text
218-
const typeName = this.getDropTypeName(type);
219256

220-
const iconAndtextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${localize('attach as context', 'Attach {0} as Context', typeName)}`);
257+
const iconAndtextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${this.getOverlayText(type)}`);
221258
const htmlElements = iconAndtextElements.map(element => {
222259
if (typeof element === 'string') {
223260
return $('span.overlay-text', undefined, element);
@@ -227,19 +264,99 @@ export class ChatDragAndDrop extends Themable {
227264

228265
this.overlayText = $('span.attach-context-overlay-text', undefined, ...htmlElements);
229266
this.overlayText.style.backgroundColor = this.overlayTextBackground;
230-
this.overlay.appendChild(this.overlayText);
267+
overlay.appendChild(this.overlayText);
231268
}
232269

233-
this.overlay.classList.toggle('visible', type !== undefined);
270+
overlay.classList.toggle('visible', type !== undefined);
271+
}
272+
273+
protected getOverlayText(type: ChatDragAndDropType): string {
274+
const typeName = this.getDropTypeName(type);
275+
return localize('attacAsContext', 'Attach {0} as Context', typeName);
276+
}
277+
278+
private updateOverlayStyles(overlay: HTMLElement): void {
279+
overlay.style.backgroundColor = this.getColor(this.styles.overlayBackground) || '';
280+
overlay.style.color = this.getColor(this.styles.listForeground) || '';
234281
}
235282

236283
override updateStyles(): void {
237-
this.overlay.style.backgroundColor = this.getColor(this.styles.overlayBackground) || '';
238-
this.overlay.style.color = this.getColor(this.styles.listForeground) || '';
284+
this.overlays.forEach(overlay => this.updateOverlayStyles(overlay.overlay));
239285
this.overlayTextBackground = this.getColor(this.styles.listBackground) || '';
240286
}
241287
}
242288

289+
export class EditsDragAndDrop extends ChatDragAndDrop {
290+
291+
constructor(
292+
attachmentModel: ChatAttachmentModel,
293+
styles: IChatInputStyles,
294+
@IThemeService themeService: IThemeService,
295+
@IExtensionService extensionService: IExtensionService,
296+
@IFileService fileService: IFileService
297+
) {
298+
super(attachmentModel, styles, themeService, extensionService, fileService);
299+
}
300+
301+
protected override handleDrop(context: IChatRequestVariableEntry[]): void {
302+
this.handleDropAsync(context);
303+
}
304+
305+
protected async handleDropAsync(context: IChatRequestVariableEntry[]): Promise<void> {
306+
const nonDirectoryContext = context.filter(context => !context.isDirectory);
307+
const directories = context
308+
.filter(context => context.isDirectory)
309+
.map(context => context.value)
310+
.filter(value => !!value && URI.isUri(value));
311+
312+
// If there are directories, we need to resolve the files and add them to the working set
313+
for (const directory of directories) {
314+
const fileSystemProvider = this.fileService.getProvider(directory.scheme);
315+
if (!fileSystemProvider) {
316+
continue;
317+
}
318+
319+
const resolvedFiles = await resolveFilesInDirectory(directory, fileSystemProvider, false);
320+
const resolvedFileContext = resolvedFiles.map(file => getResourceAttachContext(file, false)).filter(context => !!context);
321+
nonDirectoryContext.push(...resolvedFileContext);
322+
}
323+
324+
super.handleDrop(nonDirectoryContext);
325+
}
326+
327+
protected override getOverlayText(type: ChatDragAndDropType): string {
328+
const typeName = this.getDropTypeName(type);
329+
switch (type) {
330+
case ChatDragAndDropType.FILE_INTERNAL:
331+
case ChatDragAndDropType.FILE_EXTERNAL:
332+
return localize('addToWorkingSet', 'Add {0} to Working Set', typeName);
333+
case ChatDragAndDropType.FOLDER:
334+
return localize('addToWorkingSet', 'Add {0} to Working Set', localize('files', 'Files'));
335+
default:
336+
return super.getOverlayText(type);
337+
}
338+
}
339+
}
340+
341+
async function resolveFilesInDirectory(resource: URI, fileSystemProvider: IFileSystemProvider, shouldRecurse: boolean): Promise<URI[]> {
342+
const entries = await fileSystemProvider.readdir(resource);
343+
344+
const files: URI[] = [];
345+
const folders: URI[] = [];
346+
347+
for (const [name, type] of entries) {
348+
const entryResource = joinPath(resource, name);
349+
if (type === FileType.File) {
350+
files.push(entryResource);
351+
} else if (type === FileType.Directory && shouldRecurse) {
352+
folders.push(entryResource);
353+
}
354+
}
355+
356+
const subFiles = await Promise.all(folders.map(folder => resolveFilesInDirectory(folder, fileSystemProvider, shouldRecurse)));
357+
358+
return [...files, ...subFiles.flat()];
359+
}
243360

244361
function getResourceAttachContext(resource: URI, isDirectory: boolean): IChatRequestVariableEntry | undefined {
245362
return {

src/vs/workbench/contrib/chat/browser/chatImagePaste.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,7 @@ export class ChatImageDropAndPaste extends Disposable {
3737
return;
3838
}
3939

40-
const currentContextIds = this.inputPart.attachmentModel.getAttachmentIDs();
41-
const filteredContext = [];
42-
43-
if (!currentContextIds.has(context.id)) {
44-
currentContextIds.add(context.id);
45-
filteredContext.push(context);
46-
}
47-
48-
this.inputPart.attachmentModel.addContext(...filteredContext);
40+
this.inputPart.attachmentModel.addContext(context);
4941
}
5042
}
5143

src/vs/workbench/contrib/chat/browser/chatInputPart.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import { IChatWidget } from './chat.js';
8787
import { ChatAttachmentModel } from './chatAttachmentModel.js';
8888
import { IDisposableReference } from './chatContentParts/chatCollections.js';
8989
import { CollapsibleListPool, IChatCollapsibleListItem } from './chatContentParts/chatReferencesContentPart.js';
90+
import { ChatDragAndDrop, EditsDragAndDrop } from './chatDragAndDrop.js';
9091
import { ChatEditingShowChangesAction } from './chatEditing/chatEditingActions.js';
9192
import { ChatEditingSaveAllAction } from './chatEditorSaving.js';
9293
import { ChatFollowups } from './chatFollowups.js';
@@ -97,6 +98,12 @@ const $ = dom.$;
9798

9899
const INPUT_EDITOR_MAX_HEIGHT = 250;
99100

101+
export interface IChatInputStyles {
102+
overlayBackground: string;
103+
listForeground: string;
104+
listBackground: string;
105+
}
106+
100107
interface IChatInputPartOptions {
101108
renderFollowups: boolean;
102109
renderStyle?: 'compact';
@@ -191,6 +198,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
191198
return this._inputEditor;
192199
}
193200

201+
private readonly dnd: ChatDragAndDrop;
202+
194203
private history: HistoryNavigator2<IChatHistoryEntry>;
195204
private historyNavigationBackwardsEnablement!: IContextKey<boolean>;
196205
private historyNavigationForewardsEnablement!: IContextKey<boolean>;
@@ -248,6 +257,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
248257
// private readonly editorOptions: ChatEditorOptions, // TODO this should be used
249258
private readonly location: ChatAgentLocation,
250259
private readonly options: IChatInputPartOptions,
260+
styles: IChatInputStyles,
251261
getContribsInputState: () => any,
252262
@IChatWidgetHistoryService private readonly historyService: IChatWidgetHistoryService,
253263
@IModelService private readonly modelService: IModelService,
@@ -294,6 +304,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
294304
}));
295305

296306
this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar));
307+
this.dnd = this._register(this.instantiationService.createInstance(this.location === ChatAgentLocation.EditingSession ? EditsDragAndDrop : ChatDragAndDrop, this.attachmentModel, styles));
297308

298309
this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService);
299310
}
@@ -555,6 +566,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
555566
this._register(this._attachmentModel.onDidChangeContext(() => this._handleAttachedContextChange()));
556567
this.renderChatEditingSessionState(null, widget);
557568

569+
this.dnd.addOverlay(container, container);
570+
558571
const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(inputContainer));
559572
ChatContextKeys.inChatInput.bindTo(inputScopedContextKeyService).set(true);
560573
const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService])));

0 commit comments

Comments
 (0)