diff --git a/.vscode/launch.json b/.vscode/launch.json index dbcac057fed..c4227314a16 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,7 +18,7 @@ ], "pauseForSourceMap": false, "outFiles": ["${workspaceFolder}/extensions/vscode/out/extension.js"], - "preLaunchTask": "vscode-extension:build-with-packages", + "preLaunchTask": "vscode-extension:build-without-watch", "env": { // "CONTROL_PLANE_ENV": "local", "CONTINUE_GLOBAL_DIR": "${workspaceFolder}/extensions/.continue-debug" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9b34de0f1a6..ad157b57452 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -174,6 +174,25 @@ } ] }, + { + "label": "gui:build", + "type": "shell", + "command": "npm", + "options": { + "cwd": "${workspaceFolder}/gui", + "env": { + "NODE_OPTIONS": "--max-old-space-size=4096" + } + }, + "args": ["run", "build"], + "problemMatcher": ["$tsc"], + "presentation": { + "group": "build-tasks", + "panel": "shared", + "reveal": "silent", + "close": true + } + }, { "label": "binary:esbuild", "type": "shell", diff --git a/core/protocol/ide.ts b/core/protocol/ide.ts index d8b86ff960a..60a8985e9a9 100644 --- a/core/protocol/ide.ts +++ b/core/protocol/ide.ts @@ -51,6 +51,7 @@ export type ToIdeFromWebviewOrCoreProtocol = { getPinnedFiles: [undefined, string[]]; showLines: [{ filepath: string; startLine: number; endLine: number }, void]; readRangeInFile: [{ filepath: string; range: Range }, string]; + readFileAsDataUrl: [{ filepath: string }, string]; getDiff: [{ includeUnstaged: boolean }, string[]]; getTerminalContents: [undefined, string]; getDebugLocals: [{ threadIndex: number }, string]; diff --git a/extensions/vscode/src/extension/VsCodeMessenger.ts b/extensions/vscode/src/extension/VsCodeMessenger.ts index 9af399c73ae..4bf52a21666 100644 --- a/extensions/vscode/src/extension/VsCodeMessenger.ts +++ b/extensions/vscode/src/extension/VsCodeMessenger.ts @@ -287,6 +287,18 @@ export class VsCodeMessenger { }); }); + this.onWebviewOrCore("readFileAsDataUrl", async (msg) => { + const { filepath } = msg.data; + const fileUri = vscode.Uri.file(filepath); + const fileContents = await vscode.workspace.fs.readFile(fileUri); + const fileType = + filepath.split(".").pop() === "png" ? "image/png" : "image/jpeg"; + const dataUrl = `data:${fileType};base64,${Buffer.from( + fileContents, + ).toString("base64")}`; + return dataUrl; + }); + this.onWebviewOrCore("getIdeSettings", async (msg) => { return ide.getIdeSettings(); }); diff --git a/gui/src/components/mainInput/TipTapEditor/TipTapEditor.tsx b/gui/src/components/mainInput/TipTapEditor/TipTapEditor.tsx index 87c8384936d..6df190ef772 100644 --- a/gui/src/components/mainInput/TipTapEditor/TipTapEditor.tsx +++ b/gui/src/components/mainInput/TipTapEditor/TipTapEditor.tsx @@ -57,10 +57,13 @@ function TipTapEditorInner(props: TipTapEditorProps) { const historyLength = useAppSelector((store) => store.session.history.length); const isInEdit = useAppSelector((store) => store.session.isInEdit); + const [showDragOverMsg, setShowDragOverMsg] = useState(false); + const { editor, onEnterRef } = createEditorConfig({ props, ideMessenger, dispatch, + setShowDragOverMsg, }); // Register the main editor with the provider @@ -137,8 +140,6 @@ function TipTapEditorInner(props: TipTapEditorProps) { } }, [isStreaming, props.isMainInput]); - const [showDragOverMsg, setShowDragOverMsg] = useState(false); - const [activeKey, setActiveKey] = useState(null); const insertCharacterWithWhitespace = useCallback( @@ -221,40 +222,23 @@ function TipTapEditorInner(props: TipTapEditorProps) { if (e.shiftKey) { setShowDragOverMsg(false); } else { - setTimeout(() => setShowDragOverMsg(false), 2000); + setTimeout(() => { + setShowDragOverMsg(false); + }, 2000); } } + setShowDragOverMsg(false); }} onDragEnter={() => { setShowDragOverMsg(true); }} + onDragEnd={() => { + setShowDragOverMsg(false); + }} onDrop={(event) => { + // Just hide the drag overlay - ProseMirror handles the actual drop setShowDragOverMsg(false); - if ( - !defaultModel || - !modelSupportsImages( - defaultModel.provider, - defaultModel.model, - defaultModel.title, - defaultModel.capabilities, - ) - ) { - return; - } - let file = event.dataTransfer.files[0]; - void handleImageFile(ideMessenger, file).then((result) => { - if (!editor) { - return; - } - if (result) { - const [_, dataUrl] = result; - const { schema } = editor.state; - const node = schema.nodes.image.create({ src: dataUrl }); - const tr = editor.state.tr.insert(0, node); - editor.view.dispatch(tr); - } - }); - event.preventDefault(); + // Let the event bubble to ProseMirror by not preventing default }} >
@@ -299,9 +283,7 @@ function TipTapEditorInner(props: TipTapEditorProps) { defaultModel?.model || "", defaultModel?.title, defaultModel?.capabilities, - ) && ( - - )} + ) && }
); diff --git a/gui/src/components/mainInput/TipTapEditor/components/DragOverlay.tsx b/gui/src/components/mainInput/TipTapEditor/components/DragOverlay.tsx index 1417a822817..b3e935ea4ae 100644 --- a/gui/src/components/mainInput/TipTapEditor/components/DragOverlay.tsx +++ b/gui/src/components/mainInput/TipTapEditor/components/DragOverlay.tsx @@ -1,34 +1,11 @@ -import React, { useEffect } from "react"; +import React from "react"; import { HoverDiv, HoverTextDiv } from "./StyledComponents"; interface DragOverlayProps { show: boolean; - setShow: (show: boolean) => void; } -export const DragOverlay: React.FC = ({ show, setShow }) => { - useEffect(() => { - const overListener = (event: DragEvent) => { - if (event.shiftKey) return; - setShow(true); - }; - window.addEventListener("dragover", overListener); - - const leaveListener = (event: DragEvent) => { - if (event.shiftKey) { - setShow(false); - } else { - setTimeout(() => setShow(false), 2000); - } - }; - window.addEventListener("dragleave", leaveListener); - - return () => { - window.removeEventListener("dragover", overListener); - window.removeEventListener("dragleave", leaveListener); - }; - }, []); - +export const DragOverlay: React.FC = ({ show }) => { if (!show) return null; return ( diff --git a/gui/src/components/mainInput/TipTapEditor/components/StyledComponents.ts b/gui/src/components/mainInput/TipTapEditor/components/StyledComponents.ts index 3889a7ef99a..b47472ec8e9 100644 --- a/gui/src/components/mainInput/TipTapEditor/components/StyledComponents.ts +++ b/gui/src/components/mainInput/TipTapEditor/components/StyledComponents.ts @@ -56,6 +56,7 @@ export const HoverDiv = styled.div` display: flex; align-items: center; justify-content: center; + pointer-events: none; `; export const HoverTextDiv = styled.div` @@ -68,4 +69,5 @@ export const HoverTextDiv = styled.div` display: flex; align-items: center; justify-content: center; + pointer-events: none; `; diff --git a/gui/src/components/mainInput/TipTapEditor/utils/editorConfig.ts b/gui/src/components/mainInput/TipTapEditor/utils/editorConfig.ts index 20ad8ba5179..a166ff33928 100644 --- a/gui/src/components/mainInput/TipTapEditor/utils/editorConfig.ts +++ b/gui/src/components/mainInput/TipTapEditor/utils/editorConfig.ts @@ -27,7 +27,7 @@ import { getContextProviderDropdownOptions, getSlashCommandDropdownOptions, } from "./getSuggestion"; -import { handleImageFile } from "./imageUtils"; +import { handleImageFile, handleVSCodeResourceFromHtml } from "./imageUtils"; export function getPlaceholderText( placeholder: TipTapEditorProps["placeholder"], @@ -69,8 +69,9 @@ export function createEditorConfig(options: { props: TipTapEditorProps; ideMessenger: IIdeMessenger; dispatch: AppDispatch; + setShowDragOverMsg: (show: boolean) => void; }) { - const { props, ideMessenger, dispatch } = options; + const { props, ideMessenger, dispatch, setShowDragOverMsg } = options; const posthog = usePostHog(); @@ -147,6 +148,80 @@ export function createEditorConfig(options: { const plugin = new Plugin({ props: { handleDOMEvents: { + drop(view, event) { + // Hide drag overlay immediately when drop is handled + setShowDragOverMsg(false); + + // Get current model and check if it supports images + const model = defaultModelRef.current; + if ( + !model || + !modelSupportsImages( + model.provider, + model.model, + model.title, + model.capabilities, + ) + ) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + + event.preventDefault(); + event.stopPropagation(); + + // Check if dataTransfer exists + if (!event.dataTransfer) { + return true; + } + + // Handle file drop first + if (event.dataTransfer.files.length > 0) { + const file = event.dataTransfer.files[0]; + void handleImageFile(ideMessenger, file).then((result) => { + if (result) { + const [_, dataUrl] = result; + const { schema } = view.state; + const node = schema.nodes.image.create({ + src: dataUrl, + }); + const tr = view.state.tr.insert(0, node); + view.dispatch(tr); + } + }); + return true; + } + + // Handle drop of HTML content (including VS Code resource URLs) + const html = event.dataTransfer.getData("text/html"); + if (html) { + void handleVSCodeResourceFromHtml(ideMessenger, html) + .then((dataUrl) => { + if (dataUrl) { + const { schema } = view.state; + const node = schema.nodes.image.create({ + src: dataUrl, + }); + const tr = view.state.tr.insert(0, node); + view.dispatch(tr); + } + }) + .catch((err) => + console.error( + "Failed to handle VS Code resource:", + err, + ), + ); + } + + return true; + }, + dragover(view, event) { + // Allow dragover for proper drop handling + event.preventDefault(); + return true; + }, paste(view, event) { const model = defaultModelRef.current; if (!model) return; diff --git a/gui/src/components/mainInput/TipTapEditor/utils/imageUtils.ts b/gui/src/components/mainInput/TipTapEditor/utils/imageUtils.ts index 23de2dda130..027a444f37e 100644 --- a/gui/src/components/mainInput/TipTapEditor/utils/imageUtils.ts +++ b/gui/src/components/mainInput/TipTapEditor/utils/imageUtils.ts @@ -2,6 +2,159 @@ import { IIdeMessenger } from "../../../../context/IdeMessenger"; const IMAGE_RESOLUTION = 1024; +/** + * Extracts the file path from a VS Code resource URL + * Example: "https://file+.vscode-resource.vscode-cdn.net/Users/path/to/file.jpg?version=123" + * Returns: "/Users/path/to/file.jpg" + */ +export function extractFilePathFromVSCodeResourceUrl( + url: string, +): string | null { + try { + const urlObj = new URL(url); + if (urlObj.hostname === "file+.vscode-resource.vscode-cdn.net") { + return decodeURIComponent(urlObj.pathname); + } + return null; + } catch (error) { + console.error("Error parsing VS Code resource URL:", error); + return null; + } +} + +/** + * Handles VS Code resource URLs by converting them to data URLs + * @param ideMessenger - The IDE messenger to communicate with VS Code + * @param vscodeResourceUrl - The VS Code resource URL + * @returns Promise with the data URL if successful, undefined otherwise + */ +export async function handleVSCodeResourceUrl( + ideMessenger: IIdeMessenger, + vscodeResourceUrl: string, +): Promise { + const filepath = extractFilePathFromVSCodeResourceUrl(vscodeResourceUrl); + if (!filepath) { + console.error( + "Could not extract file path from VS Code resource URL:", + vscodeResourceUrl, + ); + return undefined; + } + + console.log("Extracted filepath:", filepath); + + try { + console.log("Requesting readFileAsDataUrl for filepath:", filepath); + console.log("About to call ideMessenger.request..."); + + // Add a timeout wrapper to prevent hanging + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("Request timeout after 10 seconds")), + 10000, + ), + ); + + const requestPromise = ideMessenger.request("readFileAsDataUrl", { + filepath, + }); + + console.log("Request promise created, waiting for response..."); + + const response = await Promise.race([requestPromise, timeoutPromise]); + + console.log("Got response from ideMessenger.request:", response); + console.log("Response type:", typeof response); + + // The response should be a WebviewSingleMessage which has status and content + if (response && typeof response === "object" && "status" in response) { + const typedResponse = response as { + status: string; + error?: string; + content?: string; + }; + if (typedResponse.status === "error") { + console.error("Error reading file as data URL:", typedResponse.error); + return undefined; + } + + if (typedResponse.status === "success" && typedResponse.content) { + const dataUrl = typedResponse.content; + console.log( + "Successfully got data URL for file, content length:", + dataUrl.length, + ); + return dataUrl; + } + } + + // If response is directly a string (shouldn't happen based on protocol but just in case) + if (typeof response === "string") { + console.log("Got direct string response, length:", response.length); + return response; + } + + console.error("Unexpected response format:", response); + return undefined; + } catch (error) { + console.error("Exception caught when reading file as data URL:", error); + console.error( + "Error stack:", + error instanceof Error ? error.stack : "No stack trace", + ); + return undefined; + } +} + +/** + * Extracts VS Code resource URL from HTML content + * @param html - HTML string that may contain VS Code resource URLs + * @returns The VS Code resource URL if found, null otherwise + */ +export function extractVSCodeResourceUrlFromHtml(html: string): string | null { + try { + // Create a temporary DOM element to parse the HTML + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = html; + + // Look for img tags with VS Code resource URLs + const imgTags = tempDiv.querySelectorAll("img"); + for (const img of imgTags) { + const src = img.getAttribute("src"); + if (src && src.includes("file+.vscode-resource.vscode-cdn.net")) { + return src; + } + } + + return null; + } catch (error) { + console.error("Error parsing HTML for VS Code resource URL:", error); + return null; + } +} + +/** + * Handles HTML content that contains VS Code resource URLs and converts them to usable data URLs + * @param ideMessenger - The IDE messenger to communicate with VS Code + * @param html - HTML string containing VS Code resource URLs + * @returns Promise with the data URL if successful, undefined otherwise + */ +export async function handleVSCodeResourceFromHtml( + ideMessenger: IIdeMessenger, + html: string, +): Promise { + console.log("Processing HTML for VS Code resource URL:", html); + + const vscodeResourceUrl = extractVSCodeResourceUrlFromHtml(html); + if (!vscodeResourceUrl) { + console.log("No VS Code resource URL found in HTML"); + return undefined; + } + + console.log("Found VS Code resource URL:", vscodeResourceUrl); + return await handleVSCodeResourceUrl(ideMessenger, vscodeResourceUrl); +} + export function getDataUrlForFile( file: File, img: HTMLImageElement,