Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/main/ipc/appIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
60 changes: 32 additions & 28 deletions src/renderer/components/BrowserPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -436,16 +441,16 @@ const BrowserPane: React.FC<{

return (
<div
className={cn(
'fixed bottom-0 left-0 right-0 z-[70] overflow-hidden',
paneVisible ? 'pointer-events-auto' : 'pointer-events-none'
)}
// Offset below the app titlebar so the pane’s toolbar is visible
className="pointer-events-none fixed bottom-0 left-0 right-0 z-[70] overflow-hidden"
// Offset below the app titlebar so the pane's toolbar is visible
style={{ top: 'var(--tb, 36px)' }}
aria-hidden={!paneVisible}
>
<div
className="absolute right-0 top-0 h-full border-l border-border bg-background shadow-xl"
className={cn(
'absolute right-0 top-0 h-full border-l border-border bg-background shadow-xl',
paneVisible ? 'pointer-events-auto' : 'pointer-events-none'
)}
style={{
width: `${widthPct}%`,
transform: paneVisible ? 'translateX(0)' : 'translateX(100%)',
Expand Down Expand Up @@ -492,8 +497,7 @@ const BrowserPane: React.FC<{
className="mx-2 flex min-w-0 flex-1"
onSubmit={(e) => {
e.preventDefault();
let next = address.trim();
if (!/^https?:\/\//i.test(next)) next = `http://${next}`;
const next = normalizeAddressBarUrl(address);
navigate(next);
}}
>
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/components/FileExplorer/FileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +16,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
} from '@/components/ui/context-menu';

import {
AlertDialog,
AlertDialogContent,
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -220,6 +225,7 @@ const TreeNode: React.FC<{
onContextMenuCopyRelPath={onContextMenuCopyRelPath}
onContextMenuOpenTerminal={onContextMenuOpenTerminal}
onContextMenuReveal={onContextMenuReveal}
onContextMenuOpenInBrowser={onContextMenuOpenInBrowser}
/>
))}
</div>
Expand Down Expand Up @@ -254,6 +260,11 @@ const TreeNode: React.FC<{
<ContextMenuItem onSelect={() => onContextMenuReveal?.(node)}>
Reveal in Finder
</ContextMenuItem>
{isHtmlFile(node.name) && (
<ContextMenuItem onSelect={() => onContextMenuOpenInBrowser?.(node)}>
Open in Browser Pane
</ContextMenuItem>
)}
</>
)}
</ContextMenuContent>
Expand All @@ -274,6 +285,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
connectionId,
remotePath,
}) => {
const browser = useBrowser();
const [tree, setTree] = useState<FileNode[]>([]);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
const state = getEditorState(taskId);
Expand Down Expand Up @@ -592,6 +604,13 @@ export const FileTree: React.FC<FileTreeProps> = ({
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);
Expand Down Expand Up @@ -872,6 +891,7 @@ export const FileTree: React.FC<FileTreeProps> = ({
onContextMenuCopyRelPath={handleCopyRelativePath}
onContextMenuOpenTerminal={handleOpenTerminal}
onContextMenuReveal={handleRevealInFinder}
onContextMenuOpenInBrowser={handleOpenInBrowser}
/>
))}
</div>
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/lib/browserPaneUtils.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
96 changes: 96 additions & 0 deletions src/test/renderer/browserPaneUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
Loading