Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
16 changes: 8 additions & 8 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 @@ -436,16 +437,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 +493,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
25 changes: 25 additions & 0 deletions src/renderer/lib/browserPaneUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* 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, '/');
return `file://${joined}`;
}
84 changes: 84 additions & 0 deletions src/test/renderer/browserPaneUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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');
});
});