Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/vscode/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface WorkspaceStatus {
hasWorkspace: boolean;
path?: string;
workspaceRoot?: string;
workspaceRootUri?: string;
}

export type ErrorPhase = "preflight" | "runtime";
Expand Down
1 change: 1 addition & 0 deletions apps/vscode/src/handlers/workspace.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const checkWorkspace: Handler<void, WorkspaceStatus> = async (_, ctx) => {
hasWorkspace: ctx.workDir !== null,
path: ctx.workDir ?? undefined,
workspaceRoot: ctx.workspaceRoot ?? undefined,
workspaceRootUri: ctx.workspaceRootUri?.toString(),
};
};

Expand Down
124 changes: 124 additions & 0 deletions apps/vscode/test/path-drop.test.ts
Original file line number Diff line number Diff line change
@@ -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"] });
});
});
18 changes: 15 additions & 3 deletions apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -54,7 +55,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) {
const [previewMedia, setPreviewMedia] = useState<string | null>(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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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,
Expand Down
125 changes: 125 additions & 0 deletions apps/vscode/webview-ui/src/components/inputarea/path-drop.ts
Original file line number Diff line number Diff line change
@@ -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<File>;
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,
};
}
3 changes: 2 additions & 1 deletion apps/vscode/webview-ui/src/hooks/useAppInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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(),
Expand Down
Loading