Skip to content

Commit 28cdf0e

Browse files
authored
Merge pull request RooCodeInc#1221 from RooVetGit/multi_file_drag_drop
Support multiple files in drag-and-drop
2 parents 8faac4f + a4e5870 commit 28cdf0e

File tree

3 files changed

+272
-8
lines changed

3 files changed

+272
-8
lines changed

.changeset/orange-zoos-train.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Support multiple files in drag-and-drop

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -590,15 +590,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
590590
const files = Array.from(e.dataTransfer.files)
591591
const text = e.dataTransfer.getData("text")
592592
if (text) {
593-
// Convert the path to a mention-friendly format
594-
const mentionText = convertToMentionPath(text, cwd)
593+
// Split text on newlines to handle multiple files
594+
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
595+
596+
if (lines.length > 0) {
597+
// Process each line as a separate file path
598+
let newValue = inputValue.slice(0, cursorPosition)
599+
let totalLength = 0
600+
601+
lines.forEach((line, index) => {
602+
// Convert each path to a mention-friendly format
603+
const mentionText = convertToMentionPath(line, cwd)
604+
newValue += mentionText
605+
totalLength += mentionText.length
606+
607+
// Add space after each mention except the last one
608+
if (index < lines.length - 1) {
609+
newValue += " "
610+
totalLength += 1
611+
}
612+
})
595613

596-
const newValue =
597-
inputValue.slice(0, cursorPosition) + mentionText + " " + inputValue.slice(cursorPosition)
598-
setInputValue(newValue)
599-
const newCursorPosition = cursorPosition + mentionText.length + 1
600-
setCursorPosition(newCursorPosition)
601-
setIntendedCursorPosition(newCursorPosition)
614+
// Add space after the last mention and append the rest of the input
615+
newValue += " " + inputValue.slice(cursorPosition)
616+
totalLength += 1
617+
618+
setInputValue(newValue)
619+
const newCursorPosition = cursorPosition + totalLength
620+
setCursorPosition(newCursorPosition)
621+
setIntendedCursorPosition(newCursorPosition)
622+
}
602623
return
603624
}
604625

webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import ChatTextArea from "../ChatTextArea"
33
import { useExtensionState } from "../../../context/ExtensionStateContext"
44
import { vscode } from "../../../utils/vscode"
55
import { defaultModeSlug } from "../../../../../src/shared/modes"
6+
import * as pathMentions from "../../../utils/path-mentions"
67

78
// Mock modules
89
jest.mock("../../../utils/vscode", () => ({
@@ -12,9 +13,20 @@ jest.mock("../../../utils/vscode", () => ({
1213
}))
1314
jest.mock("../../../components/common/CodeBlock")
1415
jest.mock("../../../components/common/MarkdownBlock")
16+
jest.mock("../../../utils/path-mentions", () => ({
17+
convertToMentionPath: jest.fn((path, cwd) => {
18+
// Simple mock implementation that mimics the real function's behavior
19+
if (cwd && path.toLowerCase().startsWith(cwd.toLowerCase())) {
20+
const relativePath = path.substring(cwd.length)
21+
return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
22+
}
23+
return path
24+
}),
25+
}))
1526

1627
// Get the mocked postMessage function
1728
const mockPostMessage = vscode.postMessage as jest.Mock
29+
const mockConvertToMentionPath = pathMentions.convertToMentionPath as jest.Mock
1830

1931
// Mock ExtensionStateContext
2032
jest.mock("../../../context/ExtensionStateContext")
@@ -160,4 +172,230 @@ describe("ChatTextArea", () => {
160172
expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt")
161173
})
162174
})
175+
176+
describe("multi-file drag and drop", () => {
177+
const mockCwd = "/Users/test/project"
178+
179+
beforeEach(() => {
180+
jest.clearAllMocks()
181+
;(useExtensionState as jest.Mock).mockReturnValue({
182+
filePaths: [],
183+
openedTabs: [],
184+
cwd: mockCwd,
185+
})
186+
mockConvertToMentionPath.mockClear()
187+
})
188+
189+
it("should process multiple file paths separated by newlines", () => {
190+
const setInputValue = jest.fn()
191+
192+
const { container } = render(
193+
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Initial text" />,
194+
)
195+
196+
// Create a mock dataTransfer object with text data containing multiple file paths
197+
const dataTransfer = {
198+
getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"),
199+
files: [],
200+
}
201+
202+
// Simulate drop event
203+
fireEvent.drop(container.querySelector(".chat-text-area")!, {
204+
dataTransfer,
205+
preventDefault: jest.fn(),
206+
})
207+
208+
// Verify convertToMentionPath was called for each file path
209+
expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
210+
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd)
211+
expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd)
212+
213+
// Verify setInputValue was called with the correct value
214+
// The mock implementation of convertToMentionPath will convert the paths to @/file1.js and @/file2.js
215+
expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text")
216+
})
217+
218+
it("should filter out empty lines in the dragged text", () => {
219+
const setInputValue = jest.fn()
220+
221+
const { container } = render(
222+
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Initial text" />,
223+
)
224+
225+
// Create a mock dataTransfer object with text data containing empty lines
226+
const dataTransfer = {
227+
getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n\n/Users/test/project/file2.js\n\n"),
228+
files: [],
229+
}
230+
231+
// Simulate drop event
232+
fireEvent.drop(container.querySelector(".chat-text-area")!, {
233+
dataTransfer,
234+
preventDefault: jest.fn(),
235+
})
236+
237+
// Verify convertToMentionPath was called only for non-empty lines
238+
expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
239+
240+
// Verify setInputValue was called with the correct value
241+
expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text")
242+
})
243+
244+
it("should correctly update cursor position after adding multiple mentions", () => {
245+
const setInputValue = jest.fn()
246+
const initialCursorPosition = 5
247+
248+
const { container } = render(
249+
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Hello world" />,
250+
)
251+
252+
// Set the cursor position manually
253+
const textArea = container.querySelector("textarea")
254+
if (textArea) {
255+
textArea.selectionStart = initialCursorPosition
256+
textArea.selectionEnd = initialCursorPosition
257+
}
258+
259+
// Create a mock dataTransfer object with text data
260+
const dataTransfer = {
261+
getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"),
262+
files: [],
263+
}
264+
265+
// Simulate drop event
266+
fireEvent.drop(container.querySelector(".chat-text-area")!, {
267+
dataTransfer,
268+
preventDefault: jest.fn(),
269+
})
270+
271+
// The cursor position should be updated based on the implementation in the component
272+
expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Hello world")
273+
})
274+
275+
it("should handle very long file paths correctly", () => {
276+
const setInputValue = jest.fn()
277+
278+
const { container } = render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
279+
280+
// Create a very long file path
281+
const longPath =
282+
"/Users/test/project/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript"
283+
284+
// Create a mock dataTransfer object with the long path
285+
const dataTransfer = {
286+
getData: jest.fn().mockReturnValue(longPath),
287+
files: [],
288+
}
289+
290+
// Simulate drop event
291+
fireEvent.drop(container.querySelector(".chat-text-area")!, {
292+
dataTransfer,
293+
preventDefault: jest.fn(),
294+
})
295+
296+
// Verify convertToMentionPath was called with the long path
297+
expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd)
298+
299+
// The mock implementation will convert it to @/very/long/path/...
300+
expect(setInputValue).toHaveBeenCalledWith(
301+
"@/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript ",
302+
)
303+
})
304+
305+
it("should handle paths with special characters correctly", () => {
306+
const setInputValue = jest.fn()
307+
308+
const { container } = render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
309+
310+
// Create paths with special characters
311+
const specialPath1 = "/Users/test/project/file with spaces.js"
312+
const specialPath2 = "/Users/test/project/file-with-dashes.js"
313+
const specialPath3 = "/Users/test/project/file_with_underscores.js"
314+
const specialPath4 = "/Users/test/project/file.with.dots.js"
315+
316+
// Create a mock dataTransfer object with the special paths
317+
const dataTransfer = {
318+
getData: jest
319+
.fn()
320+
.mockReturnValue(`${specialPath1}\n${specialPath2}\n${specialPath3}\n${specialPath4}`),
321+
files: [],
322+
}
323+
324+
// Simulate drop event
325+
fireEvent.drop(container.querySelector(".chat-text-area")!, {
326+
dataTransfer,
327+
preventDefault: jest.fn(),
328+
})
329+
330+
// Verify convertToMentionPath was called for each path
331+
expect(mockConvertToMentionPath).toHaveBeenCalledTimes(4)
332+
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd)
333+
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd)
334+
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd)
335+
expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd)
336+
337+
// Verify setInputValue was called with the correct value
338+
expect(setInputValue).toHaveBeenCalledWith(
339+
"@/file with spaces.js @/file-with-dashes.js @/file_with_underscores.js @/file.with.dots.js ",
340+
)
341+
})
342+
343+
it("should handle paths outside the current working directory", () => {
344+
const setInputValue = jest.fn()
345+
346+
const { container } = render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
347+
348+
// Create paths outside the current working directory
349+
const outsidePath = "/Users/other/project/file.js"
350+
351+
// Mock the convertToMentionPath function to return the original path for paths outside cwd
352+
mockConvertToMentionPath.mockImplementationOnce((path, cwd) => {
353+
return path // Return original path for this test
354+
})
355+
356+
// Create a mock dataTransfer object with the outside path
357+
const dataTransfer = {
358+
getData: jest.fn().mockReturnValue(outsidePath),
359+
files: [],
360+
}
361+
362+
// Simulate drop event
363+
fireEvent.drop(container.querySelector(".chat-text-area")!, {
364+
dataTransfer,
365+
preventDefault: jest.fn(),
366+
})
367+
368+
// Verify convertToMentionPath was called with the outside path
369+
expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd)
370+
371+
// Verify setInputValue was called with the original path
372+
expect(setInputValue).toHaveBeenCalledWith("/Users/other/project/file.js ")
373+
})
374+
375+
it("should do nothing when dropped text is empty", () => {
376+
const setInputValue = jest.fn()
377+
378+
const { container } = render(
379+
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Initial text" />,
380+
)
381+
382+
// Create a mock dataTransfer object with empty text
383+
const dataTransfer = {
384+
getData: jest.fn().mockReturnValue(""),
385+
files: [],
386+
}
387+
388+
// Simulate drop event
389+
fireEvent.drop(container.querySelector(".chat-text-area")!, {
390+
dataTransfer,
391+
preventDefault: jest.fn(),
392+
})
393+
394+
// Verify convertToMentionPath was not called
395+
expect(mockConvertToMentionPath).not.toHaveBeenCalled()
396+
397+
// Verify setInputValue was not called
398+
expect(setInputValue).not.toHaveBeenCalled()
399+
})
400+
})
163401
})

0 commit comments

Comments
 (0)