diff --git a/src/main/ipc/appIpc.ts b/src/main/ipc/appIpc.ts
index b43c64df1..1508b5d55 100644
--- a/src/main/ipc/appIpc.ts
+++ b/src/main/ipc/appIpc.ts
@@ -220,8 +220,8 @@ export function registerAppIpc() {
try {
if (!url || typeof url !== 'string') throw new Error('Invalid URL');
- // Security: Validate URL protocol to prevent local file access and dangerous protocols
- const ALLOWED_PROTOCOLS = ['http:', 'https:'];
+ // Security: Validate URL protocol to prevent dangerous protocols
+ const ALLOWED_PROTOCOLS = ['http:', 'https:', 'file:'];
let parsedUrl: URL;
try {
diff --git a/src/renderer/components/BrowserPane.tsx b/src/renderer/components/BrowserPane.tsx
index 0255740aa..cc7234a41 100644
--- a/src/renderer/components/BrowserPane.tsx
+++ b/src/renderer/components/BrowserPane.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { X, ArrowLeft, ArrowRight, ExternalLink, RotateCw } from 'lucide-react';
import { useBrowser } from '@/providers/BrowserProvider';
import { cn } from '@/lib/utils';
+import { normalizeAddressBarUrl } from '@/lib/browserPaneUtils';
import { Input } from './ui/input';
import { Spinner } from './ui/spinner';
import { Button } from './ui/button';
@@ -259,28 +260,32 @@ const BrowserPane: React.FC<{
return;
}
- requestAnimationFrame(() => {
- const bounds = computeBounds();
- if (bounds && bounds.width > 0 && bounds.height > 0) {
- if (hasBoundsChanged(bounds)) {
- lastBoundsRef.current = bounds;
- try {
- (window as any).electronAPI?.browserShow?.(bounds, url || undefined);
- setTimeout(() => {
- const updatedBounds = computeBounds();
- if (updatedBounds && updatedBounds.width > 0 && updatedBounds.height > 0) {
- if (hasBoundsChanged(updatedBounds)) {
- lastBoundsRef.current = updatedBounds;
- try {
- (window as any).electronAPI?.browserSetBounds?.(updatedBounds);
- } catch {}
+ // Delay show until after browserLoadURL has fired (URL_LOAD_DELAY_MS) to avoid
+ // a white flash caused by the WebContentsView becoming visible before the URL loads.
+ setTimeout(() => {
+ requestAnimationFrame(() => {
+ const bounds = computeBounds();
+ if (bounds && bounds.width > 0 && bounds.height > 0) {
+ if (hasBoundsChanged(bounds)) {
+ lastBoundsRef.current = bounds;
+ try {
+ (window as any).electronAPI?.browserShow?.(bounds, url || undefined);
+ setTimeout(() => {
+ const updatedBounds = computeBounds();
+ if (updatedBounds && updatedBounds.width > 0 && updatedBounds.height > 0) {
+ if (hasBoundsChanged(updatedBounds)) {
+ lastBoundsRef.current = updatedBounds;
+ try {
+ (window as any).electronAPI?.browserSetBounds?.(updatedBounds);
+ } catch {}
+ }
}
- }
- }, BOUNDS_UPDATE_DELAY_MS);
- } catch {}
+ }, BOUNDS_UPDATE_DELAY_MS);
+ } catch {}
+ }
}
- }
- });
+ });
+ }, URL_LOAD_DELAY_MS + 20);
const onResize = () => {
const bounds = computeBounds();
@@ -436,16 +441,16 @@ const BrowserPane: React.FC<{
return (
{
e.preventDefault();
- let next = address.trim();
- if (!/^https?:\/\//i.test(next)) next = `http://${next}`;
+ const next = normalizeAddressBarUrl(address);
navigate(next);
}}
>
diff --git a/src/renderer/components/FileExplorer/FileTree.tsx b/src/renderer/components/FileExplorer/FileTree.tsx
index fed1af70a..b2222c21d 100644
--- a/src/renderer/components/FileExplorer/FileTree.tsx
+++ b/src/renderer/components/FileExplorer/FileTree.tsx
@@ -6,6 +6,8 @@ import { useContentSearch } from '@/hooks/useContentSearch';
import { SearchInput } from './SearchInput';
import { ContentSearchResults } from './ContentSearchResults';
import { getEditorState, saveEditorState } from '@/lib/editorStateStorage';
+import { useBrowser } from '@/providers/BrowserProvider';
+import { isHtmlFile, buildFileUrl } from '@/lib/browserPaneUtils';
import type { FileChange } from '@/hooks/useFileChanges';
import {
ContextMenu,
@@ -14,6 +16,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
} from '@/components/ui/context-menu';
+
import {
AlertDialog,
AlertDialogContent,
@@ -102,6 +105,7 @@ const TreeNode: React.FC<{
onContextMenuCopyRelPath?: (node: FileNode) => void;
onContextMenuOpenTerminal?: (node: FileNode) => void;
onContextMenuReveal?: (node: FileNode) => void;
+ onContextMenuOpenInBrowser?: (node: FileNode) => void;
}> = ({
node,
level,
@@ -120,6 +124,7 @@ const TreeNode: React.FC<{
onContextMenuCopyRelPath,
onContextMenuOpenTerminal,
onContextMenuReveal,
+ onContextMenuOpenInBrowser,
}) => {
// Guard: if node is null or missing type, don't render
if (!node || !node.type) {
@@ -220,6 +225,7 @@ const TreeNode: React.FC<{
onContextMenuCopyRelPath={onContextMenuCopyRelPath}
onContextMenuOpenTerminal={onContextMenuOpenTerminal}
onContextMenuReveal={onContextMenuReveal}
+ onContextMenuOpenInBrowser={onContextMenuOpenInBrowser}
/>
))}
@@ -254,6 +260,11 @@ const TreeNode: React.FC<{
onContextMenuReveal?.(node)}>
Reveal in Finder
+ {isHtmlFile(node.name) && (
+
onContextMenuOpenInBrowser?.(node)}>
+ Open in Browser Pane
+
+ )}
>
)}
@@ -274,6 +285,7 @@ export const FileTree: React.FC
= ({
connectionId,
remotePath,
}) => {
+ const browser = useBrowser();
const [tree, setTree] = useState([]);
const [expandedPaths, setExpandedPaths] = useState>(() => {
const state = getEditorState(taskId);
@@ -592,6 +604,13 @@ export const FileTree: React.FC = ({
await window.electronAPI.openIn({ app: 'finder', path: filePath });
};
+ const handleOpenInBrowser = useCallback(
+ (node: FileNode) => {
+ browser.open(buildFileUrl(rootPath, node.path));
+ },
+ [rootPath, browser]
+ );
+
const handleRenameClick = (node: FileNode) => {
setRenamingNode(node);
setRenameValue(node.name);
@@ -872,6 +891,7 @@ export const FileTree: React.FC = ({
onContextMenuCopyRelPath={handleCopyRelativePath}
onContextMenuOpenTerminal={handleOpenTerminal}
onContextMenuReveal={handleRevealInFinder}
+ onContextMenuOpenInBrowser={handleOpenInBrowser}
/>
))}
diff --git a/src/renderer/lib/browserPaneUtils.ts b/src/renderer/lib/browserPaneUtils.ts
new file mode 100644
index 000000000..6f2d2ac6e
--- /dev/null
+++ b/src/renderer/lib/browserPaneUtils.ts
@@ -0,0 +1,29 @@
+/**
+ * Returns true when the filename ends with .html or .htm (case-insensitive).
+ */
+export function isHtmlFile(name: string): boolean {
+ return /\.html?$/i.test(name);
+}
+
+/**
+ * Normalises a value entered into the browser-pane address bar.
+ * - Preserves `http://`, `https://`, and `file://` URLs as-is.
+ * - Prepends `http://` to everything else so bare hostnames work.
+ */
+export function normalizeAddressBarUrl(input: string): string {
+ const trimmed = input.trim();
+ if (/^(?:https?|file):\/\//i.test(trimmed)) return trimmed;
+ return `http://${trimmed}`;
+}
+
+/**
+ * Builds a `file://` URL from a root path and a relative file path.
+ */
+export function buildFileUrl(rootPath: string, relativePath: string): string {
+ const joined = [rootPath, relativePath]
+ .filter(Boolean)
+ .join('/')
+ .replace(/[\\/]+/g, '/');
+ const absPath = joined.startsWith('/') ? joined : `/${joined}`;
+ return `file://${absPath}`;
+}
diff --git a/src/test/renderer/browserPaneUtils.test.ts b/src/test/renderer/browserPaneUtils.test.ts
new file mode 100644
index 000000000..213f449b8
--- /dev/null
+++ b/src/test/renderer/browserPaneUtils.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isHtmlFile,
+ normalizeAddressBarUrl,
+ buildFileUrl,
+} from '../../renderer/lib/browserPaneUtils';
+
+describe('isHtmlFile', () => {
+ it('returns true for .html files', () => {
+ expect(isHtmlFile('index.html')).toBe(true);
+ });
+
+ it('returns true for .htm files', () => {
+ expect(isHtmlFile('report.htm')).toBe(true);
+ });
+
+ it('is case-insensitive', () => {
+ expect(isHtmlFile('Page.HTML')).toBe(true);
+ expect(isHtmlFile('doc.HTM')).toBe(true);
+ });
+
+ it('returns false for non-HTML files', () => {
+ expect(isHtmlFile('script.js')).toBe(false);
+ expect(isHtmlFile('style.css')).toBe(false);
+ expect(isHtmlFile('data.json')).toBe(false);
+ expect(isHtmlFile('README.md')).toBe(false);
+ });
+
+ it('returns false for filenames containing html but not ending with it', () => {
+ expect(isHtmlFile('html-parser.js')).toBe(false);
+ expect(isHtmlFile('my.html.bak')).toBe(false);
+ });
+});
+
+describe('normalizeAddressBarUrl', () => {
+ it('preserves http:// URLs', () => {
+ expect(normalizeAddressBarUrl('http://localhost:3000')).toBe('http://localhost:3000');
+ });
+
+ it('preserves https:// URLs', () => {
+ expect(normalizeAddressBarUrl('https://example.com')).toBe('https://example.com');
+ });
+
+ it('preserves file:// URLs', () => {
+ expect(normalizeAddressBarUrl('file:///Users/test/index.html')).toBe(
+ 'file:///Users/test/index.html'
+ );
+ });
+
+ it('prepends http:// to bare hostnames', () => {
+ expect(normalizeAddressBarUrl('localhost:3000')).toBe('http://localhost:3000');
+ });
+
+ it('prepends http:// to bare domain names', () => {
+ expect(normalizeAddressBarUrl('example.com')).toBe('http://example.com');
+ });
+
+ it('trims whitespace', () => {
+ expect(normalizeAddressBarUrl(' https://example.com ')).toBe('https://example.com');
+ });
+
+ it('is case-insensitive for protocol detection', () => {
+ expect(normalizeAddressBarUrl('HTTP://example.com')).toBe('HTTP://example.com');
+ expect(normalizeAddressBarUrl('FILE:///tmp/test.html')).toBe('FILE:///tmp/test.html');
+ });
+});
+
+describe('buildFileUrl', () => {
+ it('builds a file:// URL from root and relative path', () => {
+ expect(buildFileUrl('/Users/test/project', 'src/index.html')).toBe(
+ 'file:///Users/test/project/src/index.html'
+ );
+ });
+
+ it('collapses duplicate slashes', () => {
+ expect(buildFileUrl('/Users/test/project/', '/src/index.html')).toBe(
+ 'file:///Users/test/project/src/index.html'
+ );
+ });
+
+ it('handles root path only', () => {
+ expect(buildFileUrl('/Users/test/index.html', '')).toBe('file:///Users/test/index.html');
+ });
+
+ it('handles Windows-style root paths', () => {
+ expect(buildFileUrl('C:/Users/test/project', 'src/index.html')).toBe(
+ 'file:///C:/Users/test/project/src/index.html'
+ );
+ });
+
+ it('normalises Windows backslashes', () => {
+ expect(buildFileUrl('C:\\Users\\test\\project', 'src\\index.html')).toBe(
+ 'file:///C:/Users/test/project/src/index.html'
+ );
+ });
+});