diff --git a/.changeset/shaggy-spies-kneel.md b/.changeset/shaggy-spies-kneel.md new file mode 100644 index 0000000000..d137cf85ef --- /dev/null +++ b/.changeset/shaggy-spies-kneel.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Add drag-and-drop for files diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 42fd7ae567..77b927d577 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2421,6 +2421,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { const allowedCommands = vscode.workspace.getConfiguration("roo-cline").get("allowedCommands") || [] + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || "" + return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, @@ -2467,6 +2469,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { experiments: experiments ?? experimentDefault, mcpServers: this.mcpHub?.getAllServers() ?? [], maxOpenTabsContext: maxOpenTabsContext ?? 20, + cwd: cwd, } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index fe9fa39427..8f64a9ba05 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -129,6 +129,7 @@ export interface ExtensionState { customModes: ModeConfig[] toolRequirements?: Record // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled) maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500) + cwd?: string // Current working directory } export interface ClineMessage { diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index d317efb96e..dc78a3fdb3 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -16,6 +16,7 @@ import { vscode } from "../../utils/vscode" import { WebviewMessage } from "../../../../src/shared/WebviewMessage" import { Mode, getAllModes } from "../../../../src/shared/modes" import { CaretIcon } from "../common/CaretIcon" +import { convertToMentionPath } from "../../utils/path-mentions" interface ChatTextAreaProps { inputValue: string @@ -50,7 +51,7 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes } = useExtensionState() + const { filePaths, openedTabs, currentApiConfigName, listApiConfigMeta, customModes, cwd } = useExtensionState() const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) @@ -589,18 +590,24 @@ const ChatTextArea = forwardRef( const files = Array.from(e.dataTransfer.files) const text = e.dataTransfer.getData("text") if (text) { - const newValue = inputValue.slice(0, cursorPosition) + text + inputValue.slice(cursorPosition) + // Convert the path to a mention-friendly format + const mentionText = convertToMentionPath(text, cwd) + + const newValue = + inputValue.slice(0, cursorPosition) + mentionText + " " + inputValue.slice(cursorPosition) setInputValue(newValue) - const newCursorPosition = cursorPosition + text.length + const newCursorPosition = cursorPosition + mentionText.length + 1 setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) return } + const acceptedTypes = ["png", "jpeg", "webp"] const imageFiles = files.filter((file) => { const [type, subtype] = file.type.split("/") return type === "image" && acceptedTypes.includes(subtype) }) + if (!shouldDisableImages && imageFiles.length > 0) { const imagePromises = imageFiles.map((file) => { return new Promise((resolve) => { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b9fc215a1c..98369cf095 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -880,9 +880,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie const placeholderText = useMemo(() => { const baseText = task ? "Type a message..." : "Type your task here..." const contextText = "(@ to add context, / to switch modes" - const imageText = shouldDisableImages ? "" : ", hold shift to drag in images" - const helpText = imageText ? `\n${contextText}${imageText})` : `\n${contextText})` - return baseText + helpText + const imageText = shouldDisableImages ? "hold shift to drag in files" : ", hold shift to drag in files/images" + return baseText + `\n${contextText}${imageText})` }, [task, shouldDisableImages]) const itemContent = useCallback( diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 3dca8d5f51..e99877d8cb 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -118,6 +118,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode autoApprovalEnabled: false, customModes: [], maxOpenTabsContext: 20, + cwd: "", }) const [didHydrateState, setDidHydrateState] = useState(false) diff --git a/webview-ui/src/utils/__tests__/path-mentions.test.ts b/webview-ui/src/utils/__tests__/path-mentions.test.ts new file mode 100644 index 0000000000..bb5591fbe5 --- /dev/null +++ b/webview-ui/src/utils/__tests__/path-mentions.test.ts @@ -0,0 +1,45 @@ +import { convertToMentionPath } from "../path-mentions" + +describe("path-mentions", () => { + describe("convertToMentionPath", () => { + it("should convert an absolute path to a mention path when it starts with cwd", () => { + // Windows-style paths + expect(convertToMentionPath("C:\\Users\\user\\project\\file.txt", "C:\\Users\\user\\project")).toBe( + "@/file.txt", + ) + + // Unix-style paths + expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project")).toBe("@/file.txt") + }) + + it("should handle paths with trailing slashes in cwd", () => { + expect(convertToMentionPath("/Users/user/project/file.txt", "/Users/user/project/")).toBe("@/file.txt") + }) + + it("should be case-insensitive when matching paths", () => { + expect(convertToMentionPath("/Users/User/Project/file.txt", "/users/user/project")).toBe("@/file.txt") + }) + + it("should return the original path when cwd is not provided", () => { + expect(convertToMentionPath("/Users/user/project/file.txt")).toBe("/Users/user/project/file.txt") + }) + + it("should return the original path when it does not start with cwd", () => { + expect(convertToMentionPath("/Users/other/project/file.txt", "/Users/user/project")).toBe( + "/Users/other/project/file.txt", + ) + }) + + it("should normalize backslashes to forward slashes", () => { + expect(convertToMentionPath("C:\\Users\\user\\project\\subdir\\file.txt", "C:\\Users\\user\\project")).toBe( + "@/subdir/file.txt", + ) + }) + + it("should handle nested paths correctly", () => { + expect(convertToMentionPath("/Users/user/project/nested/deeply/file.txt", "/Users/user/project")).toBe( + "@/nested/deeply/file.txt", + ) + }) + }) +}) diff --git a/webview-ui/src/utils/path-mentions.ts b/webview-ui/src/utils/path-mentions.ts new file mode 100644 index 0000000000..960483f593 --- /dev/null +++ b/webview-ui/src/utils/path-mentions.ts @@ -0,0 +1,38 @@ +/** + * Utilities for handling path-related operations in mentions + */ + +/** + * Converts an absolute path to a mention-friendly path + * If the provided path starts with the current working directory, + * it's converted to a relative path prefixed with @ + * + * @param path The path to convert + * @param cwd The current working directory + * @returns A mention-friendly path + */ +export function convertToMentionPath(path: string, cwd?: string): string { + const normalizedPath = path.replace(/\\/g, "/") + let normalizedCwd = cwd ? cwd.replace(/\\/g, "/") : "" + + if (!normalizedCwd) { + return path + } + + // Remove trailing slash from cwd if it exists + if (normalizedCwd.endsWith("/")) { + normalizedCwd = normalizedCwd.slice(0, -1) + } + + // Always use case-insensitive comparison for path matching + const lowerPath = normalizedPath.toLowerCase() + const lowerCwd = normalizedCwd.toLowerCase() + + if (lowerPath.startsWith(lowerCwd)) { + const relativePath = normalizedPath.substring(normalizedCwd.length) + // Ensure there's a slash after the @ symbol when we create the mention path + return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath) + } + + return path +}