Skip to content

Commit feb0cc1

Browse files
daniel-lxshannesrudolph
authored andcommitted
feat: optimize memory usage for image handling in webview (#7556)
* feat: optimize memory usage for image handling in webview - Replace base64 image data with webview URIs to reduce memory footprint - Add proper resource roots to webview for workspace file access - Implement convertToWebviewUri method for safe file-to-URI conversion - Update ImageViewer to handle both webview URIs and file paths separately - Add image message type for proper image rendering in chat - Improve error handling and display for failed image loads - Add comprehensive tests for ImageViewer component - Format display paths as relative for better readability This change significantly reduces memory usage by avoiding base64 encoding of images and instead using VSCode's webview URI system for direct file access. Images are now loaded on-demand from disk rather than being held in memory as base64 strings. * fix: address PR review comments - Use safeJsonParse instead of JSON.parse in ChatRow.tsx - Add type definition for parsed image info - Add more specific error types in ClineProvider.ts - Add comprehensive JSDoc comments to ImageBlock.tsx - Improve error handling and type safety * fix: address MrUbens' review comments - Remove hardcoded 'rc1' pattern in formatDisplayPath, use generic workspace detection - Internationalize 'No image data' text using i18n system * chore: remove useless comment * chore(i18n): add image.noData to all locales to fix translation check * test: update ImageViewer.spec to align with i18n key and flexible path formatting
1 parent 3725917 commit feb0cc1

File tree

23 files changed

+260
-56
lines changed

23 files changed

+260
-56
lines changed

src/core/tools/generateImageTool.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { fileExistsAtPath } from "../../utils/fs"
88
import { getReadablePath } from "../../utils/path"
99
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
1010
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
11-
import { safeWriteJson } from "../../utils/safeWriteJson"
1211
import { OpenRouterHandler } from "../../api/providers/openrouter"
1312

1413
// Hardcoded list of image generation models for now
@@ -237,12 +236,18 @@ export async function generateImageTool(
237236

238237
cline.didEditFile = true
239238

240-
// Display the generated image in the chat using a text message with the image
241-
await cline.say("text", getReadablePath(cline.cwd, finalPath), [result.imageData])
242-
243239
// Record successful tool usage
244240
cline.recordToolUsage("generate_image")
245241

242+
// Get the webview URI for the image
243+
const provider = cline.providerRef.deref()
244+
const fullImagePath = path.join(cline.cwd, finalPath)
245+
246+
// Convert to webview URI if provider is available
247+
const imageUri = provider?.convertToWebviewUri?.(fullImagePath) ?? vscode.Uri.file(fullImagePath).toString()
248+
249+
// Send the image with the webview URI
250+
await cline.say("image", JSON.stringify({ imageUri, imagePath: fullImagePath }))
246251
pushToolResult(formatResponse.toolResult(getReadablePath(cline.cwd, finalPath)))
247252

248253
return

src/core/webview/ClineProvider.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,9 +604,17 @@ export class ClineProvider
604604
setTtsSpeed(ttsSpeed ?? 1)
605605
})
606606

607+
// Set up webview options with proper resource roots
608+
const resourceRoots = [this.contextProxy.extensionUri]
609+
610+
// Add workspace folders to allow access to workspace files
611+
if (vscode.workspace.workspaceFolders) {
612+
resourceRoots.push(...vscode.workspace.workspaceFolders.map((folder) => folder.uri))
613+
}
614+
607615
webviewView.webview.options = {
608616
enableScripts: true,
609-
localResourceRoots: [this.contextProxy.extensionUri],
617+
localResourceRoots: resourceRoots,
610618
}
611619

612620
webviewView.webview.html =
@@ -2500,4 +2508,40 @@ public async clearTask(): Promise<void> {
25002508
public get cwd() {
25012509
return getWorkspacePath()
25022510
}
2511+
2512+
/**
2513+
* Convert a file path to a webview-accessible URI
2514+
* This method safely converts file paths to URIs that can be loaded in the webview
2515+
*
2516+
* @param filePath - The absolute file path to convert
2517+
* @returns The webview URI string, or the original file URI if conversion fails
2518+
* @throws {Error} When webview is not available
2519+
* @throws {TypeError} When file path is invalid
2520+
*/
2521+
public convertToWebviewUri(filePath: string): string {
2522+
try {
2523+
const fileUri = vscode.Uri.file(filePath)
2524+
2525+
// Check if we have a webview available
2526+
if (this.view?.webview) {
2527+
const webviewUri = this.view.webview.asWebviewUri(fileUri)
2528+
return webviewUri.toString()
2529+
}
2530+
2531+
// Specific error for no webview available
2532+
const error = new Error("No webview available for URI conversion")
2533+
console.error(error.message)
2534+
// Fallback to file URI if no webview available
2535+
return fileUri.toString()
2536+
} catch (error) {
2537+
// More specific error handling
2538+
if (error instanceof TypeError) {
2539+
console.error("Invalid file path provided for URI conversion:", error)
2540+
} else {
2541+
console.error("Failed to convert to webview URI:", error)
2542+
}
2543+
// Return file URI as fallback
2544+
return vscode.Uri.file(filePath).toString()
2545+
}
2546+
}
25032547
}

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,6 +1159,17 @@ export const ChatRowContent = ({
11591159
return <CodebaseSearchResultsDisplay results={results} />
11601160
case "user_edit_todos":
11611161
return <UpdateTodoListToolBlock userEdited onChange={() => {}} />
1162+
case "image":
1163+
// Parse the JSON to get imageUri and imagePath
1164+
const imageInfo = safeJsonParse<{ imageUri: string; imagePath: string }>(message.text || "{}")
1165+
if (!imageInfo) {
1166+
return null
1167+
}
1168+
return (
1169+
<div style={{ marginTop: "10px" }}>
1170+
<ImageBlock imageUri={imageInfo.imageUri} imagePath={imageInfo.imagePath} />
1171+
</div>
1172+
)
11621173
default:
11631174
return (
11641175
<>
Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,66 @@
11
import React from "react"
22
import { ImageViewer } from "./ImageViewer"
33

4+
/**
5+
* Props for the ImageBlock component
6+
*/
47
interface ImageBlockProps {
5-
imageData: string
8+
/**
9+
* The webview-accessible URI for rendering the image.
10+
* This is the preferred format for new image generation tools.
11+
* Should be a URI that can be directly loaded in the webview context.
12+
*/
13+
imageUri?: string
14+
15+
/**
16+
* The actual file path for display purposes and file operations.
17+
* Used to show the path to the user and for opening the file in the editor.
18+
* This is typically an absolute or relative path to the image file.
19+
*/
20+
imagePath?: string
21+
22+
/**
23+
* Base64 data or regular URL for backward compatibility.
24+
* @deprecated Use imageUri instead for new implementations.
25+
* This is maintained for compatibility with Mermaid diagrams and legacy code.
26+
*/
27+
imageData?: string
28+
29+
/**
30+
* Optional path for Mermaid diagrams.
31+
* @deprecated Use imagePath instead for new implementations.
32+
* This is maintained for backward compatibility with existing Mermaid diagram rendering.
33+
*/
634
path?: string
735
}
836

9-
export default function ImageBlock({ imageData, path }: ImageBlockProps) {
37+
export default function ImageBlock({ imageUri, imagePath, imageData, path }: ImageBlockProps) {
38+
// Determine which props to use based on what's provided
39+
let finalImageUri: string
40+
let finalImagePath: string | undefined
41+
42+
if (imageUri) {
43+
// New format: explicit imageUri and imagePath
44+
finalImageUri = imageUri
45+
finalImagePath = imagePath
46+
} else if (imageData) {
47+
// Legacy format: use imageData as direct URI (for Mermaid diagrams)
48+
finalImageUri = imageData
49+
finalImagePath = path
50+
} else {
51+
// No valid image data provided
52+
console.error("ImageBlock: No valid image data provided")
53+
return null
54+
}
55+
1056
return (
1157
<div className="my-2">
12-
<ImageViewer imageData={imageData} path={path} alt="AI Generated Image" showControls={true} />
58+
<ImageViewer
59+
imageUri={finalImageUri}
60+
imagePath={finalImagePath}
61+
alt="AI Generated Image"
62+
showControls={true}
63+
/>
1364
</div>
1465
)
1566
}

webview-ui/src/components/common/ImageViewer.tsx

Lines changed: 105 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,17 @@ const MIN_ZOOM = 0.5
1313
const MAX_ZOOM = 20
1414

1515
export interface ImageViewerProps {
16-
imageData: string // base64 data URL or regular URL
16+
imageUri: string // The URI to use for rendering (webview URI, base64, or regular URL)
17+
imagePath?: string // The actual file path for display and opening
1718
alt?: string
18-
path?: string
1919
showControls?: boolean
2020
className?: string
2121
}
2222

2323
export function ImageViewer({
24-
imageData,
24+
imageUri,
25+
imagePath,
2526
alt = "Generated image",
26-
path,
2727
showControls = true,
2828
className = "",
2929
}: ImageViewerProps) {
@@ -33,6 +33,7 @@ export function ImageViewer({
3333
const [isHovering, setIsHovering] = useState(false)
3434
const [isDragging, setIsDragging] = useState(false)
3535
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 })
36+
const [imageError, setImageError] = useState<string | null>(null)
3637
const { copyWithFeedback } = useCopyToClipboard()
3738
const { t } = useAppTranslation()
3839

@@ -53,12 +54,13 @@ export function ImageViewer({
5354
e.stopPropagation()
5455

5556
try {
56-
const textToCopy = path || imageData
57-
await copyWithFeedback(textToCopy, e)
58-
59-
// Show feedback
60-
setCopyFeedback(true)
61-
setTimeout(() => setCopyFeedback(false), 2000)
57+
// Copy the file path if available
58+
if (imagePath) {
59+
await copyWithFeedback(imagePath, e)
60+
// Show feedback
61+
setCopyFeedback(true)
62+
setTimeout(() => setCopyFeedback(false), 2000)
63+
}
6264
} catch (err) {
6365
console.error("Error copying:", err instanceof Error ? err.message : String(err))
6466
}
@@ -71,10 +73,10 @@ export function ImageViewer({
7173
e.stopPropagation()
7274

7375
try {
74-
// Send message to VSCode to save the image
76+
// Request VSCode to save the image
7577
vscode.postMessage({
7678
type: "saveImage",
77-
dataUri: imageData,
79+
dataUri: imageUri,
7880
})
7981
} catch (error) {
8082
console.error("Error saving image:", error)
@@ -86,10 +88,21 @@ export function ImageViewer({
8688
*/
8789
const handleOpenInEditor = (e: React.MouseEvent) => {
8890
e.stopPropagation()
89-
vscode.postMessage({
90-
type: "openImage",
91-
text: imageData,
92-
})
91+
// Use openImage for both file paths and data URIs
92+
// The backend will handle both cases appropriately
93+
if (imagePath) {
94+
// Use the actual file path for opening
95+
vscode.postMessage({
96+
type: "openImage",
97+
text: imagePath,
98+
})
99+
} else if (imageUri) {
100+
// Fallback to opening image URI if no path is available (for Mermaid diagrams)
101+
vscode.postMessage({
102+
type: "openImage",
103+
text: imageUri,
104+
})
105+
}
93106
}
94107

95108
/**
@@ -129,24 +142,86 @@ export function ImageViewer({
129142
setIsHovering(false)
130143
}
131144

145+
const handleImageError = useCallback(() => {
146+
setImageError("Failed to load image")
147+
}, [])
148+
149+
const handleImageLoad = useCallback(() => {
150+
setImageError(null)
151+
}, [])
152+
153+
/**
154+
* Format the display path for the image
155+
*/
156+
const formatDisplayPath = (path: string): string => {
157+
// If it's already a relative path starting with ./, keep it
158+
if (path.startsWith("./")) return path
159+
// If it's an absolute path, extract the relative portion
160+
// Look for workspace patterns - match the last segment after any directory separator
161+
const workspaceMatch = path.match(/\/([^/]+)\/(.+)$/)
162+
if (workspaceMatch && workspaceMatch[2]) {
163+
// Return relative path from what appears to be the workspace root
164+
return `./${workspaceMatch[2]}`
165+
}
166+
// Otherwise, just get the filename
167+
const filename = path.split("/").pop()
168+
return filename || path
169+
}
170+
171+
// Handle missing image URI
172+
if (!imageUri) {
173+
return (
174+
<div
175+
className={`relative w-full ${className}`}
176+
style={{
177+
minHeight: "100px",
178+
backgroundColor: "var(--vscode-editor-background)",
179+
display: "flex",
180+
alignItems: "center",
181+
justifyContent: "center",
182+
}}>
183+
<span style={{ color: "var(--vscode-descriptionForeground)" }}>{t("common:image.noData")}</span>
184+
</div>
185+
)
186+
}
187+
132188
return (
133189
<>
134190
<div
135191
className={`relative w-full ${className}`}
136192
onMouseEnter={handleMouseEnter}
137193
onMouseLeave={handleMouseLeave}>
138-
<img
139-
src={imageData}
140-
alt={alt}
141-
className="w-full h-auto rounded cursor-pointer"
142-
onClick={handleOpenInEditor}
143-
style={{
144-
maxHeight: "400px",
145-
objectFit: "contain",
146-
backgroundColor: "var(--vscode-editor-background)",
147-
}}
148-
/>
149-
{path && <div className="mt-1 text-xs text-vscode-descriptionForeground">{path}</div>}
194+
{imageError ? (
195+
<div
196+
style={{
197+
minHeight: "100px",
198+
display: "flex",
199+
alignItems: "center",
200+
justifyContent: "center",
201+
backgroundColor: "var(--vscode-editor-background)",
202+
borderRadius: "4px",
203+
padding: "20px",
204+
}}>
205+
<span style={{ color: "var(--vscode-errorForeground)" }}>⚠️ {imageError}</span>
206+
</div>
207+
) : (
208+
<img
209+
src={imageUri}
210+
alt={alt}
211+
className="w-full h-auto rounded cursor-pointer"
212+
onClick={handleOpenInEditor}
213+
onError={handleImageError}
214+
onLoad={handleImageLoad}
215+
style={{
216+
maxHeight: "400px",
217+
objectFit: "contain",
218+
backgroundColor: "var(--vscode-editor-background)",
219+
}}
220+
/>
221+
)}
222+
{imagePath && (
223+
<div className="mt-1 text-xs text-vscode-descriptionForeground">{formatDisplayPath(imagePath)}</div>
224+
)}
150225
{showControls && isHovering && (
151226
<div className="absolute bottom-2 right-2 flex gap-1 bg-vscode-editor-background/90 rounded p-0.5 z-10 opacity-100 transition-opacity duration-200 ease-in-out">
152227
<MermaidActionButtons
@@ -202,7 +277,7 @@ export function ImageViewer({
202277
onMouseUp={() => setIsDragging(false)}
203278
onMouseLeave={() => setIsDragging(false)}>
204279
<img
205-
src={imageData}
280+
src={imageUri}
206281
alt={alt}
207282
style={{
208283
maxWidth: "90vw",
@@ -225,7 +300,7 @@ export function ImageViewer({
225300
zoomInStep={0.2}
226301
zoomOutStep={-0.2}
227302
/>
228-
{path && (
303+
{imagePath && (
229304
<StandardTooltip content={t("common:mermaid.buttons.copy")}>
230305
<IconButton icon={copyFeedback ? "check" : "copy"} onClick={handleCopy} />
231306
</StandardTooltip>

webview-ui/src/i18n/locales/ca/common.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)