diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts index a16351cfde..3623310604 100644 --- a/apps/vscode/shared/types.ts +++ b/apps/vscode/shared/types.ts @@ -40,6 +40,7 @@ export interface WorkspaceStatus { hasWorkspace: boolean; path?: string; workspaceRoot?: string; + workspaceRootUri?: string; } export type ErrorPhase = "preflight" | "runtime"; diff --git a/apps/vscode/src/handlers/workspace.handler.ts b/apps/vscode/src/handlers/workspace.handler.ts index 401e5bcdc5..87fb6a8212 100644 --- a/apps/vscode/src/handlers/workspace.handler.ts +++ b/apps/vscode/src/handlers/workspace.handler.ts @@ -11,6 +11,7 @@ const checkWorkspace: Handler = async (_, ctx) => { hasWorkspace: ctx.workDir !== null, path: ctx.workDir ?? undefined, workspaceRoot: ctx.workspaceRoot ?? undefined, + workspaceRootUri: ctx.workspaceRootUri?.toString(), }; }; diff --git a/apps/vscode/test/path-drop.test.ts b/apps/vscode/test/path-drop.test.ts new file mode 100644 index 0000000000..902a4208d6 --- /dev/null +++ b/apps/vscode/test/path-drop.test.ts @@ -0,0 +1,124 @@ +/** + * Scenario: resources dragged from VS Code's Explorer into the chat webview. + * Responsibility: decode the standard URI list and present workspace resources + * as relative paths while retaining usable absolute paths for external files. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/path-drop.test.ts + */ +import { describe, expect, it } from "vitest"; + +import { + insertDroppedFilePaths, + parseDroppedFilePaths, + resolveDroppedContent, +} from "../webview-ui/src/components/inputarea/path-drop"; + +describe("parseDroppedFilePaths", () => { + it("decodes Explorer URI lists and makes workspace paths relative", () => { + const uriList = [ + "# VS Code resources", + "file:///work/project/src/User%20Service.ts#L4,2", + "file:///work/project/docs", + "", + ].join("\r\n"); + + expect(parseDroppedFilePaths(uriList, "/work/project")).toEqual([ + "src/User Service.ts", + "docs", + ]); + }); + + it("keeps external paths absolute and ignores non-file resources", () => { + const uriList = [ + "https://example.com/not-a-file", + "file:///other/project/readme.md", + "not a URI", + ].join("\n"); + + expect(parseDroppedFilePaths(uriList, "/work/project")).toEqual([ + "/other/project/readme.md", + ]); + }); + + it("supports Windows workspace paths case-insensitively", () => { + expect( + parseDroppedFilePaths("file:///C:/Users/Ada/Project/src/main.ts", "c:\\Users\\Ada\\Project"), + ).toEqual(["src/main.ts"]); + }); + + it("does not treat a sibling directory with the same prefix as workspace-relative", () => { + expect( + parseDroppedFilePaths("file:///work/project-copy/file.ts", "/work/project"), + ).toEqual(["/work/project-copy/file.ts"]); + }); + + it("represents the dropped workspace root as the current directory", () => { + expect(parseDroppedFilePaths("file:///", "/")).toEqual(["."]); + expect(parseDroppedFilePaths("file:///C:/", "C:\\")).toEqual(["."]); + }); + + it("accepts resources from the active remote workspace", () => { + expect(parseDroppedFilePaths( + "vscode-remote://ssh-remote+devbox/home/ada/project/packages/app/src/main.ts", + "/home/ada/project/packages/app", + "vscode-remote://ssh-remote+devbox/home/ada/project", + )).toEqual(["src/main.ts"]); + }); + + it("rejects remote resources from a different workspace authority", () => { + const workspaceUri = "vscode-remote://ssh-remote+devbox/home/ada/project"; + const uriList = [ + "vscode-remote://ssh-remote+other/home/ada/project/file.ts", + "vscode-remote://unexpected@ssh-remote+devbox/home/ada/project/file.ts", + "https://ssh-remote+devbox/home/ada/project/file.ts", + ].join("\n"); + + expect(parseDroppedFilePaths(uriList, "/home/ada/project", workspaceUri)).toEqual([]); + }); +}); + +describe("insertDroppedFilePaths", () => { + it("inserts mentions at the caret without replacing surrounding text", () => { + expect(insertDroppedFilePaths("Review please", 7, ["src/a.ts", "docs"])).toEqual({ + text: "Review @src/a.ts @docs please", + cursorPos: 23, + }); + }); + + it("quotes the same space-containing path form used by editor mentions", () => { + expect(insertDroppedFilePaths("", 0, ["src/User Service.ts"])).toEqual({ + text: '@"src/User Service.ts" ', + cursorPos: 23, + }); + }); +}); + +describe("resolveDroppedContent", () => { + function transfer(files: File[], uriList: string) { + return { + files, + getData: (mimeType: string) => mimeType === "text/uri-list" ? uriList : "", + }; + } + + it("preserves media upload when a drop also contains a URI list", () => { + const image = new File(["image"], "diagram.png", { type: "image/png" }); + const dropped = resolveDroppedContent( + transfer([image], "file:///work/project/diagram.png"), + "/work/project", + null, + (file) => file.type.startsWith("image/"), + ); + + expect(dropped).toEqual({ kind: "media", files: [image] }); + }); + + it("uses Explorer paths when no media file is present", () => { + const source = new File(["source"], "main.ts", { type: "text/typescript" }); + expect(resolveDroppedContent( + transfer([source], "file:///work/project/src/main.ts"), + "/work/project", + null, + (file) => file.type.startsWith("image/"), + )).toEqual({ kind: "paths", paths: ["src/main.ts"] }); + }); +}); diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index 3151005a77..ace0b0289b 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -38,6 +38,7 @@ import { useMediaUpload } from "./hooks/useMediaUpload"; import { useClickOutside } from "./hooks/useClickOutside"; import { useInputHistory } from "./hooks/useInputHistory"; import { computeMentionInsert } from "./utils"; +import { insertDroppedFilePaths } from "./path-drop"; interface InputAreaProps { onAuthAction?: () => void; @@ -54,7 +55,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { const [previewMedia, setPreviewMedia] = useState(null); const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, messages } = useChatStore(); - const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore(); + const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode, currentWorkDir, workspaceRoot, workspaceRootUri } = useSettingsStore(); const isProcessing = hasProcessingMedia(); const thinkingMode = getCurrentThinkingMode(); @@ -134,8 +135,6 @@ export function InputArea({ onAuthAction }: InputAreaProps) { const activeToken = useMemo(() => findActiveToken(text, cursorPos), [text, cursorPos]); - const { handlePaste, handlePickMedia } = useMediaUpload(); - const adjustHeight = useMemoizedFn(() => { const ta = textareaRef.current; if (ta) { @@ -144,6 +143,19 @@ export function InputArea({ onAuthAction }: InputAreaProps) { } }); + const handleDropPaths = useCallback((paths: string[]) => { + const insertion = insertDroppedFilePaths(text, cursorPos, paths); + setText(insertion.text); + setCursorPos(insertion.cursorPos); + setTimeout(() => { + textareaRef.current?.setSelectionRange(insertion.cursorPos, insertion.cursorPos); + textareaRef.current?.focus(); + adjustHeight(); + }, 0); + }, [text, cursorPos, adjustHeight]); + + const { handlePaste, handlePickMedia } = useMediaUpload(currentWorkDir || workspaceRoot, workspaceRootUri, handleDropPaths); + const { handleKey: handleHistoryKey, add: addToHistory, diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/useMediaUpload.ts b/apps/vscode/webview-ui/src/components/inputarea/hooks/useMediaUpload.ts index bca84d9426..8738b6307e 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/hooks/useMediaUpload.ts +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/useMediaUpload.ts @@ -4,6 +4,7 @@ import { bridge } from "@/services"; import { validateMediaFile, validateTotalSize, processMediaFile, getMediaType } from "@/lib/media-utils"; import { useChatStore } from "@/stores"; import { MEDIA_CONFIG } from "@/services/config"; +import { resolveDroppedContent } from "../path-drop"; interface UseMediaUploadResult { canAddMedia: boolean; @@ -12,7 +13,11 @@ interface UseMediaUploadResult { addMediaFiles: (files: File[]) => void; } -export function useMediaUpload(): UseMediaUploadResult { +export function useMediaUpload( + workspaceRoot: string | null, + workspaceRootUri: string | null, + onDropPaths: (paths: string[]) => void, +): UseMediaUploadResult { const { draftMedia, addDraftMedia, updateDraftMedia, removeDraftMedia } = useChatStore(); const hasProcessing = draftMedia.some((m) => !m.dataUri); @@ -125,6 +130,14 @@ export function useMediaUpload(): UseMediaUploadResult { const handleDocDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); + if (!e.dataTransfer) { + return; + } + const droppedContent = resolveDroppedContent(e.dataTransfer, workspaceRoot, workspaceRootUri, isMediaFile); + if (droppedContent.kind === "paths") { + onDropPaths(droppedContent.paths); + return; + } if (hasProcessing) { return; } @@ -133,7 +146,7 @@ export function useMediaUpload(): UseMediaUploadResult { return; } - const files = Array.from(e.dataTransfer?.files || []).filter(isMediaFile); + const files = droppedContent.kind === "media" ? droppedContent.files : []; if (files.length === 0) { return; } @@ -149,7 +162,7 @@ export function useMediaUpload(): UseMediaUploadResult { document.removeEventListener("dragover", handleDocDragOver); document.removeEventListener("drop", handleDocDrop); }; - }, [hasProcessing, draftMedia.length, addMediaFiles]); + }, [hasProcessing, draftMedia.length, addMediaFiles, onDropPaths, workspaceRoot, workspaceRootUri]); return { canAddMedia, diff --git a/apps/vscode/webview-ui/src/components/inputarea/path-drop.ts b/apps/vscode/webview-ui/src/components/inputarea/path-drop.ts new file mode 100644 index 0000000000..72c01eddd2 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/inputarea/path-drop.ts @@ -0,0 +1,125 @@ +const FILE_SCHEME = "file:"; +const URI_LIST_COMMENT_PREFIX = "#"; +const WINDOWS_DRIVE_PATH = /^\/[A-Za-z]:\//; +const WINDOWS_ROOT = /^[A-Za-z]:[\\/]/; +const URI_LIST_MIME_TYPE = "text/uri-list"; + +export type DroppedContent = + | { kind: "media"; files: File[] } + | { kind: "paths"; paths: string[] } + | { kind: "none" }; + +interface DropDataTransfer { + files: ArrayLike; + getData(mimeType: string): string; +} + +function resourceUriToPath(uri: URL, useWindowsSeparators: boolean): string { + let pathname = decodeURIComponent(uri.pathname); + if (WINDOWS_DRIVE_PATH.test(pathname)) { + pathname = pathname.slice(1); + } else if (uri.protocol === FILE_SCHEME && uri.hostname) { + pathname = `//${uri.hostname}${pathname}`; + } + return useWindowsSeparators ? pathname.replaceAll("/", "\\") : pathname; +} + +function relativeToWorkspace(filePath: string, workspaceRoot: string | null): string { + if (!workspaceRoot) { + return filePath; + } + + const normalizedPath = filePath.replaceAll("\\", "/"); + const normalizedRoot = workspaceRoot.replaceAll("\\", "/").replace(/\/$/, ""); + const caseInsensitive = WINDOWS_ROOT.test(workspaceRoot) || workspaceRoot.startsWith("\\\\"); + const comparedPath = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const comparedRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + + if (comparedPath.replace(/\/$/, "") === comparedRoot) { + return "."; + } + if (!comparedPath.startsWith(`${comparedRoot}/`)) { + return filePath; + } + return normalizedPath.slice(normalizedRoot.length + 1); +} + +function hasSameWorkspaceAuthority(uri: URL, workspaceUri: URL | null): boolean { + return workspaceUri !== null && + uri.username === "" && + uri.password === "" && + workspaceUri.username === "" && + workspaceUri.password === "" && + uri.protocol === workspaceUri.protocol && + uri.hostname === workspaceUri.hostname && + uri.port === workspaceUri.port; +} + +export function parseDroppedFilePaths( + uriList: string, + workspaceRoot: string | null, + workspaceRootUri: string | null = null, +): string[] { + const useWindowsSeparators = workspaceRoot !== null && + (WINDOWS_ROOT.test(workspaceRoot) || workspaceRoot.startsWith("\\\\")); + const paths: string[] = []; + let parsedWorkspaceUri: URL | null = null; + if (workspaceRootUri) { + try { + parsedWorkspaceUri = new URL(workspaceRootUri); + } catch { + // A malformed workspace URI cannot authorize non-file resource schemes. + } + } + + for (const line of uriList.split(/\r?\n/)) { + const value = line.trim(); + if (!value || value.startsWith(URI_LIST_COMMENT_PREFIX)) { + continue; + } + try { + const uri = new URL(value); + const isWorkspaceScheme = hasSameWorkspaceAuthority(uri, parsedWorkspaceUri); + if (uri.protocol !== FILE_SCHEME && !isWorkspaceScheme) { + continue; + } + paths.push(relativeToWorkspace(resourceUriToPath(uri, useWindowsSeparators), workspaceRoot)); + } catch { + // A URI list may contain malformed or unsupported entries; valid file URIs still apply. + } + } + return paths; +} + +export function resolveDroppedContent( + dataTransfer: DropDataTransfer, + workspaceRoot: string | null, + workspaceRootUri: string | null, + isMediaFile: (file: File) => boolean, +): DroppedContent { + const mediaFiles = Array.from(dataTransfer.files).filter(isMediaFile); + if (mediaFiles.length > 0) { + return { kind: "media", files: mediaFiles }; + } + + const paths = parseDroppedFilePaths( + dataTransfer.getData(URI_LIST_MIME_TYPE), + workspaceRoot, + workspaceRootUri, + ); + return paths.length > 0 ? { kind: "paths", paths } : { kind: "none" }; +} + +export function insertDroppedFilePaths( + text: string, + cursorPos: number, + paths: string[], +): { text: string; cursorPos: number } { + const mentions = paths.map((path) => path.includes(" ") ? `@"${path}"` : `@${path}`).join(" "); + const insertion = `${mentions} `; + const after = text.slice(cursorPos).replace(/^\s/, ""); + return { + text: text.slice(0, cursorPos) + insertion + after, + cursorPos: cursorPos + insertion.length, + }; +} diff --git a/apps/vscode/webview-ui/src/hooks/useAppInit.ts b/apps/vscode/webview-ui/src/hooks/useAppInit.ts index c592b8b7ee..cfa9b6c8a0 100644 --- a/apps/vscode/webview-ui/src/hooks/useAppInit.ts +++ b/apps/vscode/webview-ui/src/hooks/useAppInit.ts @@ -58,7 +58,7 @@ export function useAppInit(): AppInitState { modelsCount: 0, }); const [initKey, setInitKey] = useState(0); - const { initModels, setExtensionConfig, setMCPServers, setWireSlashCommands, setIsLoggedIn, setWorkspaceRoot } = useSettingsStore(); + const { initModels, setExtensionConfig, setMCPServers, setWireSlashCommands, setIsLoggedIn, setWorkspaceRoot, setWorkspaceRootUri } = useSettingsStore(); const refresh = useCallback(() => { setState({ status: "loading", errorMessage: null, modelsCount: 0 }); @@ -87,6 +87,7 @@ export function useAppInit(): AppInitState { } setWorkspaceRoot(workspace.workspaceRoot ?? workspace.path ?? null); + setWorkspaceRootUri(workspace.workspaceRootUri ?? null); const [extensionConfig, mcpServers, slashCommands] = await Promise.all([ bridge.getExtensionConfig(), diff --git a/apps/vscode/webview-ui/src/stores/settings.store.ts b/apps/vscode/webview-ui/src/stores/settings.store.ts index 4c8c4e7a18..7ddc79886d 100644 --- a/apps/vscode/webview-ui/src/stores/settings.store.ts +++ b/apps/vscode/webview-ui/src/stores/settings.store.ts @@ -148,6 +148,7 @@ interface SettingsState { workDirModalOpen: boolean; currentWorkDir: string | null; workspaceRoot: string | null; + workspaceRootUri: string | null; models: ModelConfig[]; defaultModel: string | null; defaultThinking: boolean; @@ -168,6 +169,7 @@ interface SettingsState { setWorkDirModalOpen: (open: boolean) => void; setCurrentWorkDir: (workDir: string | null) => void; setWorkspaceRoot: (root: string | null) => void; + setWorkspaceRootUri: (uri: string | null) => void; initModels: (models: ModelConfig[], defaultModel: string | null, defaultThinking: boolean, defaultThinkingEffort?: string) => void; setWireSlashCommands: (commands: SlashCommandInfo[]) => void; setIsLoggedIn: (loggedIn: boolean) => void; @@ -183,6 +185,7 @@ export const useSettingsStore = create((set, get) => ({ workDirModalOpen: false, currentWorkDir: null, workspaceRoot: null, + workspaceRootUri: null, models: [], defaultModel: null, defaultThinking: false, @@ -284,6 +287,7 @@ export const useSettingsStore = create((set, get) => ({ setCurrentWorkDir: (currentWorkDir) => set({ currentWorkDir }), setWorkspaceRoot: (workspaceRoot) => set({ workspaceRoot }), + setWorkspaceRootUri: (workspaceRootUri) => set({ workspaceRootUri }), initModels: (models, defaultModel, defaultThinking, defaultThinkingEffort) => { settingsSaveRevision += 1;