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' + ); + }); +});