feat: add 'Save Image As...' option to image context menu#237
feat: add 'Save Image As...' option to image context menu#237pip-owl wants to merge 1 commit intoMultiboxLabs:mainfrom
Conversation
Add a Save Image option to the right-click context menu for images, alongside the existing Open Image in New Tab, Copy Image, and Copy Image Address actions. - Import dialog, net, writeFile, and path for download/save support - Add downloadImage helper that fetches the image via net.fetch and writes it to disk - Show a native save dialog with the original filename as default and common image format filters - Pass the BrowserWindow to createImageItems so the save dialog can be parented correctly
WalkthroughThis change adds image saving functionality to the context menu by extending the Changes
Sequence DiagramsequenceDiagram
participant User
participant ContextMenu as Context Menu
participant Dialog as File Dialog
participant Net as Net Module
participant FileSystem as File System
User->>ContextMenu: Right-click image
ContextMenu->>Dialog: showSaveDialog()
Dialog->>Dialog: User selects path
Dialog-->>ContextMenu: Confirm with path
ContextMenu->>Net: downloadImage(url)
Net->>Net: Fetch resource
Net-->>ContextMenu: Image data
ContextMenu->>FileSystem: Write to disk
FileSystem-->>ContextMenu: Save complete
ContextMenu->>User: Image saved
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip You can disable the changed files summary in the walkthrough.Disable the |
Greptile SummaryAdds a "Save Image As..." right-click context menu item for images, using Electron's
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant ContextMenu
participant Dialog as Electron Save Dialog
participant Net as net.fetch
participant FS as fs.writeFile
User->>ContextMenu: Right-click image → "Save Image As..."
ContextMenu->>ContextMenu: Parse srcURL, extract filename
ContextMenu->>Dialog: showSaveDialog(defaultPath, filters)
Dialog-->>ContextMenu: filePath (or cancel)
alt User selected a path
ContextMenu->>Net: fetch(srcURL)
Net-->>ContextMenu: Response (image data)
ContextMenu->>FS: writeFile(filePath, buffer)
FS-->>ContextMenu: Success
else User cancelled
ContextMenu->>ContextMenu: No-op
end
Last reviewed commit: 28c8cb8 |
| const url = new URL(parameters.srcURL); | ||
| const originalFilename = path.basename(url.pathname) || "image"; |
There was a problem hiding this comment.
Data URIs produce broken default filenames
When parameters.srcURL is a data:image/png;base64,… URI (common for inline images), new URL(dataURI).pathname returns something like image/png;base64,iVBOR... — the entire base64 payload. path.basename() on that yields the full base64 string as the default filename in the save dialog, which can be megabytes long.
Consider checking the URL protocol first and falling back to a sensible default:
| const url = new URL(parameters.srcURL); | |
| const originalFilename = path.basename(url.pathname) || "image"; | |
| const url = new URL(parameters.srcURL); | |
| let originalFilename = "image"; | |
| if (url.protocol !== "data:") { | |
| originalFilename = path.basename(url.pathname) || "image"; | |
| } |
| async function downloadImage(url: string, filePath: string): Promise<void> { | ||
| const response = await net.fetch(url); | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to download image: ${response.status} ${response.statusText}`); | ||
| } | ||
| const buffer = Buffer.from(await response.arrayBuffer()); | ||
| await writeFile(filePath, buffer); | ||
| } |
There was a problem hiding this comment.
net.fetch can't reach renderer-scoped blob URLs
parameters.srcURL can be a blob: URL (e.g. for canvas-generated or dynamically created images). Blob URLs are scoped to the renderer process that created them, and Electron's net.fetch in the main process cannot access them — this will throw a network error. Since the error is caught silently at line 303, the user will select a save location and then nothing happens with no indication of failure.
Consider either using webContents.session.fetch() (which can resolve blob URLs from the renderer's context) instead of net.fetch, or showing an error dialog to the user when the download fails.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/controllers/tabs-controller/context-menu.ts`:
- Around line 263-270: In downloadImage, validate the provided url string by
constructing a URL and only allowing schemes "http:" or "https:" (throw a
descriptive Error for other schemes), and add a fetch timeout using an
AbortController: start a timer that calls controller.abort() after a
configurable timeout, pass controller.signal to net.fetch(url, { signal }),
clear the timer on success or on any error to avoid leaks, then keep the
existing response.ok check and writeFile(Buffer.from(await
response.arrayBuffer()), filePath) behavior; reference the downloadImage
function, net.fetch call, response.ok check, AbortController for timeout
handling, and writeFile/Buffer.from for the file write.
- Around line 285-306: Detect data URLs before creating a URL object by checking
parameters.srcURL.startsWith("data:"), and if so parse its mime type to pick a
sensible default filename/extension (e.g., "image.png") and decode the base64
payload to write to the chosen file path instead of calling downloadImage;
otherwise continue using new URL(parameters.srcURL) and downloadImage as before.
Replace path.basename(url.pathname) usage for data URLs with the derived
filename. In the catch block, call dialog.showErrorBox with a user-friendly
title and the error message (include error.toString()) so failures are visible
to the user, and ensure any thrown/decode errors are propagated to that handler.
Use the existing symbols: parameters.srcURL, downloadImage,
dialog.showSaveDialog, dialog.showErrorBox, path.basename, and
browserWindow.browserWindow to locate and implement the changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 9bc6cd1e-a66b-4455-85be-dde61d1fabb1
📒 Files selected for processing (1)
src/main/controllers/tabs-controller/context-menu.ts
| async function downloadImage(url: string, filePath: string): Promise<void> { | ||
| const response = await net.fetch(url); | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to download image: ${response.status} ${response.statusText}`); | ||
| } | ||
| const buffer = Buffer.from(await response.arrayBuffer()); | ||
| await writeFile(filePath, buffer); | ||
| } |
There was a problem hiding this comment.
Add URL scheme validation and consider timeout.
Two concerns with this implementation:
-
Security: No validation of URL scheme before fetching. Should restrict to
http:andhttps:to prevent potential abuse withfile://or other schemes. -
Reliability: No timeout on the fetch request. A slow or unresponsive server could hang the operation indefinitely.
🛡️ Proposed fix with scheme validation and timeout
async function downloadImage(url: string, filePath: string): Promise<void> {
+ const parsedUrl = new URL(url);
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
+ throw new Error(`Unsupported URL scheme: ${parsedUrl.protocol}`);
+ }
+
- const response = await net.fetch(url);
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
+
+ try {
+ const response = await net.fetch(url, { signal: controller.signal });
- if (!response.ok) {
- throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
+ if (!response.ok) {
+ throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
+ }
+ const buffer = Buffer.from(await response.arrayBuffer());
+ await writeFile(filePath, buffer);
+ } finally {
+ clearTimeout(timeoutId);
}
- const buffer = Buffer.from(await response.arrayBuffer());
- await writeFile(filePath, buffer);
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/controllers/tabs-controller/context-menu.ts` around lines 263 - 270,
In downloadImage, validate the provided url string by constructing a URL and
only allowing schemes "http:" or "https:" (throw a descriptive Error for other
schemes), and add a fetch timeout using an AbortController: start a timer that
calls controller.abort() after a configurable timeout, pass controller.signal to
net.fetch(url, { signal }), clear the timer on success or on any error to avoid
leaks, then keep the existing response.ok check and writeFile(Buffer.from(await
response.arrayBuffer()), filePath) behavior; reference the downloadImage
function, net.fetch call, response.ok check, AbortController for timeout
handling, and writeFile/Buffer.from for the file write.
| { | ||
| label: "Save Image As...", | ||
| click: async () => { | ||
| try { | ||
| const url = new URL(parameters.srcURL); | ||
| const originalFilename = path.basename(url.pathname) || "image"; | ||
|
|
||
| const { filePath } = await dialog.showSaveDialog(browserWindow.browserWindow, { | ||
| defaultPath: originalFilename, | ||
| filters: [ | ||
| { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, | ||
| { name: "All Files", extensions: ["*"] } | ||
| ] | ||
| }); | ||
|
|
||
| if (filePath) { | ||
| await downloadImage(parameters.srcURL, filePath); | ||
| } | ||
| } catch (error) { | ||
| console.error("Failed to save image:", error); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Does Electron net.fetch support fetching data URLs?
💡 Result:
No. Electron’s net.fetch() explicitly does not support the data: scheme (and also does not support blob:). (electronjs.org)
Citations:
🏁 Script executed:
rg -n "downloadImage" --type ts --type tsxRepository: MultiboxLabs/flow-browser
Length of output: 96
🏁 Script executed:
rg -n "downloadImage" --type tsRepository: MultiboxLabs/flow-browser
Length of output: 321
🏁 Script executed:
sed -n '263,284p' src/main/controllers/tabs-controller/context-menu.tsRepository: MultiboxLabs/flow-browser
Length of output: 758
🏁 Script executed:
rg -n "createImageItems\|hasImageContents" src/main/controllers/tabs-controller/context-menu.ts -B 2 -A 2Repository: MultiboxLabs/flow-browser
Length of output: 51
🏁 Script executed:
rg -n "Save Image As" src/main/controllers/tabs-controller/context-menu.ts -B 5 -A 15Repository: MultiboxLabs/flow-browser
Length of output: 862
🏁 Script executed:
sed -n '275,310p' src/main/controllers/tabs-controller/context-menu.tsRepository: MultiboxLabs/flow-browser
Length of output: 1137
🏁 Script executed:
rg -n "createImageItems" src/main/controllers/tabs-controller/context-menu.ts -B 5 -A 5Repository: MultiboxLabs/flow-browser
Length of output: 978
🏁 Script executed:
sed -n '50,100p' src/main/controllers/tabs-controller/context-menu.tsRepository: MultiboxLabs/flow-browser
Length of output: 2294
🏁 Script executed:
sed -n '1,30p' src/main/controllers/tabs-controller/context-menu.tsRepository: MultiboxLabs/flow-browser
Length of output: 1103
Handle data URLs and provide user feedback on failure.
Two issues with this implementation:
-
Data URL handling: If
parameters.srcURLis a data URL (e.g.,data:image/png;base64,...), the save will fail silently because Electron'snet.fetch()does not support thedata:scheme. Additionally,path.basename(url.pathname)would extract garbage from the base64 data. Consider detecting data URLs and providing a sensible default filename with extension, and ensure the error is properly handled. -
Silent failure: When an error occurs, only
console.erroris called. The user has no indication that the save failed. Show an error dialog viadialog.showErrorBoxso users are aware when the operation fails.
🔧 Proposed fix for data URL handling and error feedback
try {
const url = new URL(parameters.srcURL);
- const originalFilename = path.basename(url.pathname) || "image";
+ let originalFilename = "image.png";
+
+ if (url.protocol !== "data:") {
+ const basename = path.basename(url.pathname);
+ if (basename && basename !== "/") {
+ originalFilename = basename;
+ }
+ } else {
+ // Extract extension from data URL mime type (e.g., data:image/png;base64,...)
+ const mimeMatch = parameters.srcURL.match(/^data:image\/(\w+)/);
+ if (mimeMatch) {
+ originalFilename = `image.${mimeMatch[1] === "jpeg" ? "jpg" : mimeMatch[1]}`;
+ }
+ }
const { filePath } = await dialog.showSaveDialog(browserWindow.browserWindow, {
defaultPath: originalFilename,
@@ ...
if (filePath) {
await downloadImage(parameters.srcURL, filePath);
}
} catch (error) {
console.error("Failed to save image:", error);
+ dialog.showErrorBox("Save Image Failed", "An error occurred while saving the image.");
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| { | |
| label: "Save Image As...", | |
| click: async () => { | |
| try { | |
| const url = new URL(parameters.srcURL); | |
| const originalFilename = path.basename(url.pathname) || "image"; | |
| const { filePath } = await dialog.showSaveDialog(browserWindow.browserWindow, { | |
| defaultPath: originalFilename, | |
| filters: [ | |
| { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, | |
| { name: "All Files", extensions: ["*"] } | |
| ] | |
| }); | |
| if (filePath) { | |
| await downloadImage(parameters.srcURL, filePath); | |
| } | |
| } catch (error) { | |
| console.error("Failed to save image:", error); | |
| } | |
| } | |
| { | |
| label: "Save Image As...", | |
| click: async () => { | |
| try { | |
| const url = new URL(parameters.srcURL); | |
| let originalFilename = "image.png"; | |
| if (url.protocol !== "data:") { | |
| const basename = path.basename(url.pathname); | |
| if (basename && basename !== "/") { | |
| originalFilename = basename; | |
| } | |
| } else { | |
| // Extract extension from data URL mime type (e.g., data:image/png;base64,...) | |
| const mimeMatch = parameters.srcURL.match(/^data:image\/(\w+)/); | |
| if (mimeMatch) { | |
| originalFilename = `image.${mimeMatch[1] === "jpeg" ? "jpg" : mimeMatch[1]}`; | |
| } | |
| } | |
| const { filePath } = await dialog.showSaveDialog(browserWindow.browserWindow, { | |
| defaultPath: originalFilename, | |
| filters: [ | |
| { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, | |
| { name: "All Files", extensions: ["*"] } | |
| ] | |
| }); | |
| if (filePath) { | |
| await downloadImage(parameters.srcURL, filePath); | |
| } | |
| } catch (error) { | |
| console.error("Failed to save image:", error); | |
| dialog.showErrorBox("Save Image Failed", "An error occurred while saving the image."); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/controllers/tabs-controller/context-menu.ts` around lines 285 - 306,
Detect data URLs before creating a URL object by checking
parameters.srcURL.startsWith("data:"), and if so parse its mime type to pick a
sensible default filename/extension (e.g., "image.png") and decode the base64
payload to write to the chosen file path instead of calling downloadImage;
otherwise continue using new URL(parameters.srcURL) and downloadImage as before.
Replace path.basename(url.pathname) usage for data URLs with the derived
filename. In the catch block, call dialog.showErrorBox with a user-friendly
title and the error message (include error.toString()) so failures are visible
to the user, and ensure any thrown/decode errors are propagated to that handler.
Use the existing symbols: parameters.srcURL, downloadImage,
dialog.showSaveDialog, dialog.showErrorBox, path.basename, and
browserWindow.browserWindow to locate and implement the changes.
Summary
net.fetchand writes it to the user-selected pathChanges
src/main/controllers/tabs-controller/context-menu.tsdialog,net(from Electron),writeFile(fromfs/promises), andpathdownloadImagehelper function that fetches an image URL and writes it to diskcreateImageItemswith a native save dialogcreateImageItemssignature to accept theBrowserWindowso the save dialog is properly parentedTesting
bun typecheckpassesbun lintpassesSummary by CodeRabbit
Release Notes