Skip to content

feat: add 'Save Image As...' option to image context menu#237

Open
pip-owl wants to merge 1 commit intoMultiboxLabs:mainfrom
pip-owl:opencode/save-image-context-menu
Open

feat: add 'Save Image As...' option to image context menu#237
pip-owl wants to merge 1 commit intoMultiboxLabs:mainfrom
pip-owl:opencode/save-image-context-menu

Conversation

@pip-owl
Copy link
Contributor

@pip-owl pip-owl commented Mar 14, 2026

Summary

  • Adds a Save Image As... option to the right-click context menu when clicking on images, placed between "Open Image in New Tab" and "Copy Image"
  • Uses Electron's native save dialog with the original filename as the default and common image format filters (PNG, JPG, GIF, WebP, SVG, BMP)
  • Downloads the image via net.fetch and writes it to the user-selected path

Changes

src/main/controllers/tabs-controller/context-menu.ts

  • Added imports for dialog, net (from Electron), writeFile (from fs/promises), and path
  • Added downloadImage helper function that fetches an image URL and writes it to disk
  • Added "Save Image As..." menu item to createImageItems with a native save dialog
  • Updated createImageItems signature to accept the BrowserWindow so the save dialog is properly parented

Testing

  • bun typecheck passes
  • bun lint passes

Summary by CodeRabbit

Release Notes

  • New Features
    • Added "Save Image As..." option to the context menu, allowing users to save images directly to their computer using a native file browser dialog.

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
@pip-owl pip-owl requested a review from iamEvanYT as a code owner March 14, 2026 17:17
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

Walkthrough

This change adds image saving functionality to the context menu by extending the createImageItems function to accept a BrowserWindow parameter, implementing a helper function to download and save images via the net module, and introducing a new "Save Image As..." menu item with native file dialog integration.

Changes

Cohort / File(s) Summary
Context Menu Image Saving
src/main/controllers/tabs-controller/context-menu.ts
Extended createImageItems signature to accept BrowserWindow; added downloadImage helper for fetching and saving images; introduced "Save Image As..." menu item with dialog and error handling.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A whisker-twitch of joy! Save images with a bound,
Dialog windows open, paths are now found,
From net to disk, the download takes flight,
Context menus dance—oh, what a delight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: adding a 'Save Image As...' option to the image context menu, which is the core feature of this changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable the changed files summary in the walkthrough.

Disable the reviews.changed_files_summary setting to disable the changed files summary in the walkthrough.

@greptile-apps
Copy link

greptile-apps bot commented Mar 14, 2026

Greptile Summary

Adds a "Save Image As..." right-click context menu item for images, using Electron's dialog.showSaveDialog and net.fetch to download and save the image file. The implementation is clean and well-structured, following existing patterns in the codebase.

  • The downloadImage helper and save dialog integration work correctly for standard http:/https: image URLs
  • Bug: data: image URIs (common for inline/embedded images) cause path.basename() to return the entire base64 payload as the default filename, which can be megabytes long
  • Bug: blob: URLs (canvas-generated or dynamically created images) are renderer-scoped and can't be fetched via net.fetch in the main process — the save will fail silently with no user feedback

Confidence Score: 3/5

  • Works for standard HTTP(S) images but has edge-case bugs for data: and blob: image URLs that are common on the web.
  • Score of 3 reflects that the core feature works correctly for the most common case (HTTP/HTTPS image URLs), but two bugs — broken default filenames for data URIs and silent failures for blob URLs — affect real-world scenarios frequently enough to warrant fixes before merging.
  • src/main/controllers/tabs-controller/context-menu.ts — the downloadImage helper and filename extraction logic need handling for data: and blob: URL schemes.

Important Files Changed

Filename Overview
src/main/controllers/tabs-controller/context-menu.ts Adds "Save Image As..." menu item with download logic. Works for HTTP(S) images but has issues with data: URIs (broken default filenames) and blob: URLs (silent download failures).

Sequence Diagram

sequenceDiagram
    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
Loading

Last reviewed commit: 28c8cb8

Comment on lines +289 to +290
const url = new URL(parameters.srcURL);
const originalFilename = path.basename(url.pathname) || "image";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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";
}

Comment on lines +263 to +270
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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5b94882 and 28c8cb8.

📒 Files selected for processing (1)
  • src/main/controllers/tabs-controller/context-menu.ts

Comment on lines +263 to +270
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);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add URL scheme validation and consider timeout.

Two concerns with this implementation:

  1. Security: No validation of URL scheme before fetching. Should restrict to http: and https: to prevent potential abuse with file:// or other schemes.

  2. 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.

Comment on lines +285 to +306
{
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);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 tsx

Repository: MultiboxLabs/flow-browser

Length of output: 96


🏁 Script executed:

rg -n "downloadImage" --type ts

Repository: MultiboxLabs/flow-browser

Length of output: 321


🏁 Script executed:

sed -n '263,284p' src/main/controllers/tabs-controller/context-menu.ts

Repository: MultiboxLabs/flow-browser

Length of output: 758


🏁 Script executed:

rg -n "createImageItems\|hasImageContents" src/main/controllers/tabs-controller/context-menu.ts -B 2 -A 2

Repository: 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 15

Repository: MultiboxLabs/flow-browser

Length of output: 862


🏁 Script executed:

sed -n '275,310p' src/main/controllers/tabs-controller/context-menu.ts

Repository: MultiboxLabs/flow-browser

Length of output: 1137


🏁 Script executed:

rg -n "createImageItems" src/main/controllers/tabs-controller/context-menu.ts -B 5 -A 5

Repository: MultiboxLabs/flow-browser

Length of output: 978


🏁 Script executed:

sed -n '50,100p' src/main/controllers/tabs-controller/context-menu.ts

Repository: MultiboxLabs/flow-browser

Length of output: 2294


🏁 Script executed:

sed -n '1,30p' src/main/controllers/tabs-controller/context-menu.ts

Repository: MultiboxLabs/flow-browser

Length of output: 1103


Handle data URLs and provide user feedback on failure.

Two issues with this implementation:

  1. Data URL handling: If parameters.srcURL is a data URL (e.g., data:image/png;base64,...), the save will fail silently because Electron's net.fetch() does not support the data: 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.

  2. Silent failure: When an error occurs, only console.error is called. The user has no indication that the save failed. Show an error dialog via dialog.showErrorBox so 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.

Suggested change
{
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants