Browse Source

Merge pull request #1221 from RooVetGit/multi_file_drag_drop

Support multiple files in drag-and-drop
Matt Rubens 10 months ago
parent
commit
28cdf0e838

+ 5 - 0
.changeset/orange-zoos-train.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Support multiple files in drag-and-drop

+ 29 - 8
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -590,15 +590,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					const files = Array.from(e.dataTransfer.files)
 					const text = e.dataTransfer.getData("text")
 					if (text) {
-						// Convert the path to a mention-friendly format
-						const mentionText = convertToMentionPath(text, cwd)
+						// Split text on newlines to handle multiple files
+						const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
+
+						if (lines.length > 0) {
+							// Process each line as a separate file path
+							let newValue = inputValue.slice(0, cursorPosition)
+							let totalLength = 0
+
+							lines.forEach((line, index) => {
+								// Convert each path to a mention-friendly format
+								const mentionText = convertToMentionPath(line, cwd)
+								newValue += mentionText
+								totalLength += mentionText.length
+
+								// Add space after each mention except the last one
+								if (index < lines.length - 1) {
+									newValue += " "
+									totalLength += 1
+								}
+							})
 
-						const newValue =
-							inputValue.slice(0, cursorPosition) + mentionText + " " + inputValue.slice(cursorPosition)
-						setInputValue(newValue)
-						const newCursorPosition = cursorPosition + mentionText.length + 1
-						setCursorPosition(newCursorPosition)
-						setIntendedCursorPosition(newCursorPosition)
+							// Add space after the last mention and append the rest of the input
+							newValue += " " + inputValue.slice(cursorPosition)
+							totalLength += 1
+
+							setInputValue(newValue)
+							const newCursorPosition = cursorPosition + totalLength
+							setCursorPosition(newCursorPosition)
+							setIntendedCursorPosition(newCursorPosition)
+						}
 						return
 					}
 

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

@@ -3,6 +3,7 @@ import ChatTextArea from "../ChatTextArea"
 import { useExtensionState } from "../../../context/ExtensionStateContext"
 import { vscode } from "../../../utils/vscode"
 import { defaultModeSlug } from "../../../../../src/shared/modes"
+import * as pathMentions from "../../../utils/path-mentions"
 
 // Mock modules
 jest.mock("../../../utils/vscode", () => ({
@@ -12,9 +13,20 @@ jest.mock("../../../utils/vscode", () => ({
 }))
 jest.mock("../../../components/common/CodeBlock")
 jest.mock("../../../components/common/MarkdownBlock")
+jest.mock("../../../utils/path-mentions", () => ({
+	convertToMentionPath: jest.fn((path, cwd) => {
+		// Simple mock implementation that mimics the real function's behavior
+		if (cwd && path.toLowerCase().startsWith(cwd.toLowerCase())) {
+			const relativePath = path.substring(cwd.length)
+			return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
+		}
+		return path
+	}),
+}))
 
 // Get the mocked postMessage function
 const mockPostMessage = vscode.postMessage as jest.Mock
+const mockConvertToMentionPath = pathMentions.convertToMentionPath as jest.Mock
 
 // Mock ExtensionStateContext
 jest.mock("../../../context/ExtensionStateContext")
@@ -160,4 +172,230 @@ describe("ChatTextArea", () => {
 			expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt")
 		})
 	})
+
+	describe("multi-file drag and drop", () => {
+		const mockCwd = "/Users/test/project"
+
+		beforeEach(() => {
+			jest.clearAllMocks()
+			;(useExtensionState as jest.Mock).mockReturnValue({
+				filePaths: [],
+				openedTabs: [],
+				cwd: mockCwd,
+			})
+			mockConvertToMentionPath.mockClear()
+		})
+
+		it("should process multiple file paths separated by newlines", () => {
+			const setInputValue = jest.fn()
+
+			const { container } = render(
+				<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Initial text" />,
+			)
+
+			// Create a mock dataTransfer object with text data containing multiple file paths
+			const dataTransfer = {
+				getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"),
+				files: [],
+			}
+
+			// Simulate drop event
+			fireEvent.drop(container.querySelector(".chat-text-area")!, {
+				dataTransfer,
+				preventDefault: jest.fn(),
+			})
+
+			// Verify convertToMentionPath was called for each file path
+			expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd)
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd)
+
+			// Verify setInputValue was called with the correct value
+			// The mock implementation of convertToMentionPath will convert the paths to @/file1.js and @/file2.js
+			expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text")
+		})
+
+		it("should filter out empty lines in the dragged text", () => {
+			const setInputValue = jest.fn()
+
+			const { container } = render(
+				<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Initial text" />,
+			)
+
+			// Create a mock dataTransfer object with text data containing empty lines
+			const dataTransfer = {
+				getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n\n/Users/test/project/file2.js\n\n"),
+				files: [],
+			}
+
+			// Simulate drop event
+			fireEvent.drop(container.querySelector(".chat-text-area")!, {
+				dataTransfer,
+				preventDefault: jest.fn(),
+			})
+
+			// Verify convertToMentionPath was called only for non-empty lines
+			expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
+
+			// Verify setInputValue was called with the correct value
+			expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text")
+		})
+
+		it("should correctly update cursor position after adding multiple mentions", () => {
+			const setInputValue = jest.fn()
+			const initialCursorPosition = 5
+
+			const { container } = render(
+				<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Hello world" />,
+			)
+
+			// Set the cursor position manually
+			const textArea = container.querySelector("textarea")
+			if (textArea) {
+				textArea.selectionStart = initialCursorPosition
+				textArea.selectionEnd = initialCursorPosition
+			}
+
+			// Create a mock dataTransfer object with text data
+			const dataTransfer = {
+				getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"),
+				files: [],
+			}
+
+			// Simulate drop event
+			fireEvent.drop(container.querySelector(".chat-text-area")!, {
+				dataTransfer,
+				preventDefault: jest.fn(),
+			})
+
+			// The cursor position should be updated based on the implementation in the component
+			expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Hello world")
+		})
+
+		it("should handle very long file paths correctly", () => {
+			const setInputValue = jest.fn()
+
+			const { container } = render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
+
+			// Create a very long file path
+			const longPath =
+				"/Users/test/project/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript"
+
+			// Create a mock dataTransfer object with the long path
+			const dataTransfer = {
+				getData: jest.fn().mockReturnValue(longPath),
+				files: [],
+			}
+
+			// Simulate drop event
+			fireEvent.drop(container.querySelector(".chat-text-area")!, {
+				dataTransfer,
+				preventDefault: jest.fn(),
+			})
+
+			// Verify convertToMentionPath was called with the long path
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd)
+
+			// The mock implementation will convert it to @/very/long/path/...
+			expect(setInputValue).toHaveBeenCalledWith(
+				"@/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript ",
+			)
+		})
+
+		it("should handle paths with special characters correctly", () => {
+			const setInputValue = jest.fn()
+
+			const { container } = render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
+
+			// Create paths with special characters
+			const specialPath1 = "/Users/test/project/file with spaces.js"
+			const specialPath2 = "/Users/test/project/file-with-dashes.js"
+			const specialPath3 = "/Users/test/project/file_with_underscores.js"
+			const specialPath4 = "/Users/test/project/file.with.dots.js"
+
+			// Create a mock dataTransfer object with the special paths
+			const dataTransfer = {
+				getData: jest
+					.fn()
+					.mockReturnValue(`${specialPath1}\n${specialPath2}\n${specialPath3}\n${specialPath4}`),
+				files: [],
+			}
+
+			// Simulate drop event
+			fireEvent.drop(container.querySelector(".chat-text-area")!, {
+				dataTransfer,
+				preventDefault: jest.fn(),
+			})
+
+			// Verify convertToMentionPath was called for each path
+			expect(mockConvertToMentionPath).toHaveBeenCalledTimes(4)
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd)
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd)
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd)
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd)
+
+			// Verify setInputValue was called with the correct value
+			expect(setInputValue).toHaveBeenCalledWith(
+				"@/file with spaces.js @/file-with-dashes.js @/file_with_underscores.js @/file.with.dots.js ",
+			)
+		})
+
+		it("should handle paths outside the current working directory", () => {
+			const setInputValue = jest.fn()
+
+			const { container } = render(<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="" />)
+
+			// Create paths outside the current working directory
+			const outsidePath = "/Users/other/project/file.js"
+
+			// Mock the convertToMentionPath function to return the original path for paths outside cwd
+			mockConvertToMentionPath.mockImplementationOnce((path, cwd) => {
+				return path // Return original path for this test
+			})
+
+			// Create a mock dataTransfer object with the outside path
+			const dataTransfer = {
+				getData: jest.fn().mockReturnValue(outsidePath),
+				files: [],
+			}
+
+			// Simulate drop event
+			fireEvent.drop(container.querySelector(".chat-text-area")!, {
+				dataTransfer,
+				preventDefault: jest.fn(),
+			})
+
+			// Verify convertToMentionPath was called with the outside path
+			expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd)
+
+			// Verify setInputValue was called with the original path
+			expect(setInputValue).toHaveBeenCalledWith("/Users/other/project/file.js ")
+		})
+
+		it("should do nothing when dropped text is empty", () => {
+			const setInputValue = jest.fn()
+
+			const { container } = render(
+				<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Initial text" />,
+			)
+
+			// Create a mock dataTransfer object with empty text
+			const dataTransfer = {
+				getData: jest.fn().mockReturnValue(""),
+				files: [],
+			}
+
+			// Simulate drop event
+			fireEvent.drop(container.querySelector(".chat-text-area")!, {
+				dataTransfer,
+				preventDefault: jest.fn(),
+			})
+
+			// Verify convertToMentionPath was not called
+			expect(mockConvertToMentionPath).not.toHaveBeenCalled()
+
+			// Verify setInputValue was not called
+			expect(setInputValue).not.toHaveBeenCalled()
+		})
+	})
 })