mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
added tabs
This commit is contained in:
parent
8991c562d0
commit
25f28564d8
4 changed files with 436 additions and 232 deletions
|
|
@ -12,6 +12,9 @@ type InvokeHandler<K extends keyof IPCChannels> = (
|
|||
type BrowserHandlers = {
|
||||
'browser:setBounds': InvokeHandler<'browser:setBounds'>;
|
||||
'browser:setVisible': InvokeHandler<'browser:setVisible'>;
|
||||
'browser:newTab': InvokeHandler<'browser:newTab'>;
|
||||
'browser:switchTab': InvokeHandler<'browser:switchTab'>;
|
||||
'browser:closeTab': InvokeHandler<'browser:closeTab'>;
|
||||
'browser:navigate': InvokeHandler<'browser:navigate'>;
|
||||
'browser:back': InvokeHandler<'browser:back'>;
|
||||
'browser:forward': InvokeHandler<'browser:forward'>;
|
||||
|
|
@ -34,6 +37,15 @@ export const browserIpcHandlers: BrowserHandlers = {
|
|||
browserViewManager.setVisible(args.visible);
|
||||
return { ok: true };
|
||||
},
|
||||
'browser:newTab': async (_event, args) => {
|
||||
return browserViewManager.newTab(args.url);
|
||||
},
|
||||
'browser:switchTab': async (_event, args) => {
|
||||
return browserViewManager.switchTab(args.tabId);
|
||||
},
|
||||
'browser:closeTab': async (_event, args) => {
|
||||
return browserViewManager.closeTab(args.tabId);
|
||||
},
|
||||
'browser:navigate': async (_event, args) => {
|
||||
return browserViewManager.navigate(args.url);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { BrowserWindow, WebContentsView, session, shell } from 'electron';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { BrowserWindow, WebContentsView, session, shell, type Session } from 'electron';
|
||||
|
||||
/**
|
||||
* Embedded browser pane implementation.
|
||||
*
|
||||
* A single lazy-created WebContentsView is hosted on top of the main
|
||||
* BrowserWindow's contentView, positioned by pixel bounds the renderer
|
||||
* computes via ResizeObserver.
|
||||
* Each browser tab owns its own WebContentsView. Only the active tab's view is
|
||||
* attached to the main window at a time, but inactive tabs keep their own page
|
||||
* history and loaded state in memory so switching tabs feels immediate.
|
||||
*
|
||||
* The view uses a persistent session partition so cookies/localStorage/
|
||||
* form-fill state survive app restarts, and spoofs a standard Chrome UA so
|
||||
* sites like Google (OAuth) don't reject it as an embedded browser.
|
||||
* All tabs share one persistent session partition so cookies/localStorage/
|
||||
* form-fill state survive app restarts, and the browser surface spoofs a
|
||||
* standard Chrome UA so sites like Google (OAuth) don't reject it.
|
||||
*/
|
||||
|
||||
const PARTITION = 'persist:rowboat-browser';
|
||||
|
|
@ -29,7 +30,8 @@ export interface BrowserBounds {
|
|||
height: number;
|
||||
}
|
||||
|
||||
export interface BrowserState {
|
||||
export interface BrowserTabState {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
canGoBack: boolean;
|
||||
|
|
@ -37,18 +39,28 @@ export interface BrowserState {
|
|||
loading: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserState {
|
||||
activeTabId: string | null;
|
||||
tabs: BrowserTabState[];
|
||||
}
|
||||
|
||||
type BrowserTab = {
|
||||
id: string;
|
||||
view: WebContentsView;
|
||||
};
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
url: '',
|
||||
title: '',
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loading: false,
|
||||
activeTabId: null,
|
||||
tabs: [],
|
||||
};
|
||||
|
||||
export class BrowserViewManager extends EventEmitter {
|
||||
private window: BrowserWindow | null = null;
|
||||
private view: WebContentsView | null = null;
|
||||
private attached = false;
|
||||
private browserSession: Session | null = null;
|
||||
private tabs = new Map<string, BrowserTab>();
|
||||
private tabOrder: string[] = [];
|
||||
private activeTabId: string | null = null;
|
||||
private attachedTabId: string | null = null;
|
||||
private visible = false;
|
||||
private bounds: BrowserBounds = { x: 0, y: 0, width: 0, height: 0 };
|
||||
|
||||
|
|
@ -56,54 +68,78 @@ export class BrowserViewManager extends EventEmitter {
|
|||
this.window = window;
|
||||
window.on('closed', () => {
|
||||
this.window = null;
|
||||
this.view = null;
|
||||
this.attached = false;
|
||||
this.browserSession = null;
|
||||
this.tabs.clear();
|
||||
this.tabOrder = [];
|
||||
this.activeTabId = null;
|
||||
this.attachedTabId = null;
|
||||
this.visible = false;
|
||||
});
|
||||
}
|
||||
|
||||
private ensureView(): WebContentsView {
|
||||
if (this.view) return this.view;
|
||||
if (!this.window) {
|
||||
throw new Error('BrowserViewManager: no window attached');
|
||||
}
|
||||
|
||||
// One shared session across all BrowserViewManager instances in this
|
||||
// process, keyed by partition name. Setting the UA on the session covers
|
||||
// requests the webContents issues before the first page is loaded.
|
||||
private getSession(): Session {
|
||||
if (this.browserSession) return this.browserSession;
|
||||
const browserSession = session.fromPartition(PARTITION);
|
||||
browserSession.setUserAgent(SPOOF_UA);
|
||||
this.browserSession = browserSession;
|
||||
return browserSession;
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
this.emit('state-updated', this.snapshotState());
|
||||
}
|
||||
|
||||
private getTab(tabId: string | null): BrowserTab | null {
|
||||
if (!tabId) return null;
|
||||
return this.tabs.get(tabId) ?? null;
|
||||
}
|
||||
|
||||
private getActiveTab(): BrowserTab | null {
|
||||
return this.getTab(this.activeTabId);
|
||||
}
|
||||
|
||||
private normalizeUrl(rawUrl: string): string {
|
||||
let url = rawUrl.trim();
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private isEmbeddedTabUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url) || url === 'about:blank';
|
||||
}
|
||||
|
||||
private createView(tabId: string): WebContentsView {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: browserSession,
|
||||
session: this.getSession(),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Also set UA on the webContents directly — belt-and-braces for sites
|
||||
// that inspect the request-level UA vs the navigator UA.
|
||||
view.webContents.setUserAgent(SPOOF_UA);
|
||||
|
||||
this.wireEvents(view);
|
||||
this.view = view;
|
||||
this.wireEvents(tabId, view);
|
||||
return view;
|
||||
}
|
||||
|
||||
private wireEvents(view: WebContentsView): void {
|
||||
private wireEvents(tabId: string, view: WebContentsView): void {
|
||||
const wc = view.webContents;
|
||||
|
||||
const emit = () => this.emit('state-updated', this.snapshotState());
|
||||
const emit = () => this.emitState();
|
||||
|
||||
// Defensively re-apply bounds on navigation events. Electron's
|
||||
// WebContentsView is known to occasionally reset its laid-out bounds on
|
||||
// navigation (a behavior carried over from the deprecated BrowserView),
|
||||
// which manifests as the view "spilling" outside its intended pane.
|
||||
// Re-applying after every navigation/load event is cheap and idempotent.
|
||||
// Electron occasionally drops WebContentsView layout on navigation.
|
||||
// Re-applying the cached bounds is cheap and keeps the active tab pinned
|
||||
// to the renderer-computed viewport.
|
||||
const reapplyBounds = () => {
|
||||
if (this.attached && this.bounds.width > 0 && this.bounds.height > 0) {
|
||||
if (
|
||||
this.attachedTabId === tabId &&
|
||||
this.visible &&
|
||||
this.bounds.width > 0 &&
|
||||
this.bounds.height > 0
|
||||
) {
|
||||
view.setBounds(this.bounds);
|
||||
}
|
||||
};
|
||||
|
|
@ -118,105 +154,20 @@ export class BrowserViewManager extends EventEmitter {
|
|||
wc.on('did-fail-load', () => { reapplyBounds(); emit(); });
|
||||
wc.on('page-title-updated', emit);
|
||||
|
||||
// Pop-ups / target="_blank" — hand off to the OS browser for now.
|
||||
// The embedded pane is single-tab in v1.
|
||||
wc.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url);
|
||||
if (this.isEmbeddedTabUrl(url)) {
|
||||
void this.newTab(url);
|
||||
} else {
|
||||
void shell.openExternal(url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
if (!this.window) return;
|
||||
const view = visible ? this.ensureView() : this.view;
|
||||
if (!view) return;
|
||||
|
||||
const contentView = this.window.contentView;
|
||||
|
||||
if (visible) {
|
||||
// Order: attach FIRST, then setBounds. Calling setBounds on an
|
||||
// unattached WebContentsView can leave it in a state where the next
|
||||
// attach uses default bounds, blanking the renderer area.
|
||||
if (!this.attached) {
|
||||
contentView.addChildView(view);
|
||||
this.attached = true;
|
||||
}
|
||||
// The renderer only asks us to show the view after it has pushed a
|
||||
// fresh non-zero rect, so applying the cached bounds here should land
|
||||
// the surface in the correct pane immediately on attach.
|
||||
view.setBounds(this.bounds);
|
||||
this.visible = true;
|
||||
|
||||
// First-time load — land on a useful page rather than about:blank.
|
||||
const currentUrl = view.webContents.getURL();
|
||||
if (!currentUrl || currentUrl === 'about:blank') {
|
||||
void view.webContents.loadURL(HOME_URL);
|
||||
}
|
||||
} else {
|
||||
if (this.attached) {
|
||||
contentView.removeChildView(view);
|
||||
this.attached = false;
|
||||
}
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
setBounds(bounds: BrowserBounds): void {
|
||||
this.bounds = bounds;
|
||||
// Only apply to the view if it's currently attached and visible.
|
||||
// Applying to a detached view appears to put Electron's WebContentsView
|
||||
// into a bad state on subsequent attach (renderer area blanks out).
|
||||
if (this.view && this.attached && this.visible) {
|
||||
this.view.setBounds(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const view = this.ensureView();
|
||||
// If the user typed "example.com" without a scheme, assume https.
|
||||
// Schemes are already filtered at the IPC boundary, so we know it's
|
||||
// not file://, javascript:, etc.
|
||||
let url = rawUrl.trim();
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
await view.webContents.loadURL(url);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
back(): { ok: boolean } {
|
||||
if (!this.view) return { ok: false };
|
||||
const history = this.view.webContents.navigationHistory;
|
||||
if (!history.canGoBack()) return { ok: false };
|
||||
history.goBack();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
forward(): { ok: boolean } {
|
||||
if (!this.view) return { ok: false };
|
||||
const history = this.view.webContents.navigationHistory;
|
||||
if (!history.canGoForward()) return { ok: false };
|
||||
history.goForward();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
if (!this.view) return;
|
||||
this.view.webContents.reload();
|
||||
}
|
||||
|
||||
getState(): BrowserState {
|
||||
return this.snapshotState();
|
||||
}
|
||||
|
||||
private snapshotState(): BrowserState {
|
||||
if (!this.view) return { ...EMPTY_STATE };
|
||||
const wc = this.view.webContents;
|
||||
private snapshotTabState(tab: BrowserTab): BrowserTabState {
|
||||
const wc = tab.view.webContents;
|
||||
return {
|
||||
id: tab.id,
|
||||
url: wc.getURL(),
|
||||
title: wc.getTitle(),
|
||||
canGoBack: wc.navigationHistory.canGoBack(),
|
||||
|
|
@ -224,6 +175,189 @@ export class BrowserViewManager extends EventEmitter {
|
|||
loading: wc.isLoading(),
|
||||
};
|
||||
}
|
||||
|
||||
private syncAttachedView(): void {
|
||||
if (!this.window) return;
|
||||
|
||||
const contentView = this.window.contentView;
|
||||
const activeTab = this.getActiveTab();
|
||||
|
||||
if (!this.visible || !activeTab) {
|
||||
const attachedTab = this.getTab(this.attachedTabId);
|
||||
if (attachedTab) {
|
||||
contentView.removeChildView(attachedTab.view);
|
||||
}
|
||||
this.attachedTabId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.attachedTabId && this.attachedTabId !== activeTab.id) {
|
||||
const attachedTab = this.getTab(this.attachedTabId);
|
||||
if (attachedTab) {
|
||||
contentView.removeChildView(attachedTab.view);
|
||||
}
|
||||
this.attachedTabId = null;
|
||||
}
|
||||
|
||||
if (this.attachedTabId !== activeTab.id) {
|
||||
contentView.addChildView(activeTab.view);
|
||||
this.attachedTabId = activeTab.id;
|
||||
}
|
||||
|
||||
if (this.bounds.width > 0 && this.bounds.height > 0) {
|
||||
activeTab.view.setBounds(this.bounds);
|
||||
}
|
||||
}
|
||||
|
||||
private createTab(initialUrl: string): BrowserTab {
|
||||
if (!this.window) {
|
||||
throw new Error('BrowserViewManager: no window attached');
|
||||
}
|
||||
|
||||
const tabId = randomUUID();
|
||||
const tab: BrowserTab = {
|
||||
id: tabId,
|
||||
view: this.createView(tabId),
|
||||
};
|
||||
|
||||
this.tabs.set(tabId, tab);
|
||||
this.tabOrder.push(tabId);
|
||||
this.activeTabId = tabId;
|
||||
this.syncAttachedView();
|
||||
this.emitState();
|
||||
|
||||
const targetUrl =
|
||||
initialUrl === 'about:blank'
|
||||
? HOME_URL
|
||||
: this.normalizeUrl(initialUrl);
|
||||
void tab.view.webContents.loadURL(targetUrl).catch(() => {
|
||||
this.emitState();
|
||||
});
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
private ensureInitialTab(): BrowserTab {
|
||||
const activeTab = this.getActiveTab();
|
||||
if (activeTab) return activeTab;
|
||||
return this.createTab(HOME_URL);
|
||||
}
|
||||
|
||||
private destroyTab(tab: BrowserTab): void {
|
||||
tab.view.webContents.removeAllListeners();
|
||||
if (!tab.view.webContents.isDestroyed()) {
|
||||
tab.view.webContents.close();
|
||||
}
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.visible = visible;
|
||||
if (visible) {
|
||||
this.ensureInitialTab();
|
||||
}
|
||||
this.syncAttachedView();
|
||||
}
|
||||
|
||||
setBounds(bounds: BrowserBounds): void {
|
||||
this.bounds = bounds;
|
||||
const activeTab = this.getActiveTab();
|
||||
if (activeTab && this.attachedTabId === activeTab.id && this.visible) {
|
||||
activeTab.view.setBounds(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
async newTab(rawUrl?: string): Promise<{ ok: boolean; tabId?: string; error?: string }> {
|
||||
try {
|
||||
const tab = this.createTab(rawUrl?.trim() ? rawUrl : HOME_URL);
|
||||
return { ok: true, tabId: tab.id };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
switchTab(tabId: string): { ok: boolean } {
|
||||
if (!this.tabs.has(tabId)) return { ok: false };
|
||||
if (this.activeTabId === tabId) return { ok: true };
|
||||
this.activeTabId = tabId;
|
||||
this.syncAttachedView();
|
||||
this.emitState();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
closeTab(tabId: string): { ok: boolean } {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return { ok: false };
|
||||
if (this.tabOrder.length <= 1) return { ok: false };
|
||||
|
||||
const closingIndex = this.tabOrder.indexOf(tabId);
|
||||
const nextActiveTabId =
|
||||
this.activeTabId === tabId
|
||||
? this.tabOrder[closingIndex + 1] ?? this.tabOrder[closingIndex - 1] ?? null
|
||||
: this.activeTabId;
|
||||
|
||||
if (this.attachedTabId === tabId && this.window) {
|
||||
this.window.contentView.removeChildView(tab.view);
|
||||
this.attachedTabId = null;
|
||||
}
|
||||
|
||||
this.tabs.delete(tabId);
|
||||
this.tabOrder = this.tabOrder.filter((id) => id !== tabId);
|
||||
this.activeTabId = nextActiveTabId;
|
||||
this.destroyTab(tab);
|
||||
this.syncAttachedView();
|
||||
this.emitState();
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async navigate(rawUrl: string): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const activeTab = this.getActiveTab() ?? this.ensureInitialTab();
|
||||
await activeTab.view.webContents.loadURL(this.normalizeUrl(rawUrl));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
back(): { ok: boolean } {
|
||||
const activeTab = this.getActiveTab();
|
||||
if (!activeTab) return { ok: false };
|
||||
const history = activeTab.view.webContents.navigationHistory;
|
||||
if (!history.canGoBack()) return { ok: false };
|
||||
history.goBack();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
forward(): { ok: boolean } {
|
||||
const activeTab = this.getActiveTab();
|
||||
if (!activeTab) return { ok: false };
|
||||
const history = activeTab.view.webContents.navigationHistory;
|
||||
if (!history.canGoForward()) return { ok: false };
|
||||
history.goForward();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const activeTab = this.getActiveTab();
|
||||
if (!activeTab) return;
|
||||
activeTab.view.webContents.reload();
|
||||
}
|
||||
|
||||
getState(): BrowserState {
|
||||
return this.snapshotState();
|
||||
}
|
||||
|
||||
private snapshotState(): BrowserState {
|
||||
if (this.tabOrder.length === 0) return { ...EMPTY_STATE };
|
||||
return {
|
||||
activeTabId: this.activeTabId,
|
||||
tabs: this.tabOrder
|
||||
.map((tabId) => this.tabs.get(tabId))
|
||||
.filter((tab): tab is BrowserTab => tab != null)
|
||||
.map((tab) => this.snapshotTabState(tab)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const browserViewManager = new BrowserViewManager();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, RotateCw, Loader2, X } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Loader2, Plus, RotateCw, X } from 'lucide-react'
|
||||
|
||||
import { TabBar } from '@/components/tab-bar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
|
|
@ -9,11 +10,12 @@ import { cn } from '@/lib/utils'
|
|||
* Renders a transparent placeholder div whose bounds are reported to the
|
||||
* main process via `browser:setBounds`. The actual browsing surface is an
|
||||
* Electron WebContentsView layered on top of the renderer by the main
|
||||
* process — this component only owns the chrome (address bar, nav, spinner)
|
||||
* and the sizing/visibility lifecycle.
|
||||
* process — this component only owns the chrome (tabs, address bar, nav
|
||||
* buttons) and the sizing/visibility lifecycle.
|
||||
*/
|
||||
|
||||
interface BrowserState {
|
||||
interface BrowserTabState {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
canGoBack: boolean
|
||||
|
|
@ -21,58 +23,73 @@ interface BrowserState {
|
|||
loading: boolean
|
||||
}
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
url: '',
|
||||
title: '',
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loading: false,
|
||||
interface BrowserState {
|
||||
activeTabId: string | null
|
||||
tabs: BrowserTabState[]
|
||||
}
|
||||
|
||||
const EMPTY_STATE: BrowserState = {
|
||||
activeTabId: null,
|
||||
tabs: [],
|
||||
}
|
||||
|
||||
/** Placeholder height we subtract from the inner bounds for the chrome row. */
|
||||
const CHROME_HEIGHT = 40
|
||||
|
||||
interface BrowserPaneProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const getActiveTab = (state: BrowserState) =>
|
||||
state.tabs.find((tab) => tab.id === state.activeTabId) ?? null
|
||||
|
||||
const getBrowserTabTitle = (tab: BrowserTabState) => {
|
||||
const title = tab.title.trim()
|
||||
if (title) return title
|
||||
const url = tab.url.trim()
|
||||
if (!url) return 'New tab'
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.hostname || parsed.href
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//i, '') || 'New tab'
|
||||
}
|
||||
}
|
||||
|
||||
export function BrowserPane({ onClose }: BrowserPaneProps) {
|
||||
const [state, setState] = useState<BrowserState>(EMPTY_STATE)
|
||||
const [addressValue, setAddressValue] = useState('')
|
||||
|
||||
const activeTabIdRef = useRef<string | null>(null)
|
||||
const addressFocusedRef = useRef(false)
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const lastBoundsRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
|
||||
const viewVisibleRef = useRef(false)
|
||||
|
||||
// ── Subscribe to state updates from main ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
|
||||
const next = incoming as BrowserState
|
||||
setState(next)
|
||||
if (!addressFocusedRef.current) {
|
||||
setAddressValue(next.url)
|
||||
}
|
||||
})
|
||||
// Kick an initial state fetch so the chrome reflects wherever the view
|
||||
// was left from a previous session (or empty, if never loaded).
|
||||
void window.ipc.invoke('browser:getState', null).then((initial) => {
|
||||
const next = initial as BrowserState
|
||||
setState(next)
|
||||
if (!addressFocusedRef.current) {
|
||||
setAddressValue(next.url)
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
const activeTab = getActiveTab(state)
|
||||
|
||||
const applyState = useCallback((next: BrowserState) => {
|
||||
const previousActiveTabId = activeTabIdRef.current
|
||||
activeTabIdRef.current = next.activeTabId
|
||||
setState(next)
|
||||
|
||||
const nextActiveTab = getActiveTab(next)
|
||||
if (!addressFocusedRef.current || next.activeTabId !== previousActiveTabId) {
|
||||
setAddressValue(nextActiveTab?.url ?? '')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Bounds tracking ───────────────────────────────────────────────────────
|
||||
// The main process needs pixel-accurate bounds *relative to the window
|
||||
// content area*. getBoundingClientRect() returns viewport-relative coords,
|
||||
// which in Electron with hiddenInset titleBar equal content-area coords.
|
||||
//
|
||||
// Reads layout synchronously and posts an IPC update only when the rect
|
||||
// actually changed. Cheap enough to call from a RAF loop or observer.
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('browser:didUpdateState', (incoming) => {
|
||||
applyState(incoming as BrowserState)
|
||||
})
|
||||
|
||||
void window.ipc.invoke('browser:getState', null).then((initial) => {
|
||||
applyState(initial as BrowserState)
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [applyState])
|
||||
|
||||
const setViewVisible = useCallback((visible: boolean) => {
|
||||
if (viewVisibleRef.current === visible) return
|
||||
viewVisibleRef.current = visible
|
||||
|
|
@ -82,6 +99,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
const measureBounds = useCallback(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return null
|
||||
|
||||
const zoomFactor = Math.max(window.electronUtils.getZoomFactor(), 0.01)
|
||||
const rect = el.getBoundingClientRect()
|
||||
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
||||
|
|
@ -89,25 +107,25 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
const clampedRightCss = chatSidebarRect && chatSidebarRect.width > 0
|
||||
? Math.min(rect.right, chatSidebarRect.left)
|
||||
: rect.right
|
||||
|
||||
// `getBoundingClientRect()` is reported in zoomed CSS pixels. Electron's
|
||||
// native view bounds are in unzoomed window coordinates, so we have to
|
||||
// convert back using the renderer zoom factor.
|
||||
// native view bounds are in unzoomed window coordinates, so convert back
|
||||
// using the renderer zoom factor before calling into the main process.
|
||||
const left = Math.ceil(rect.left * zoomFactor)
|
||||
const top = Math.ceil(rect.top * zoomFactor)
|
||||
const right = Math.floor(clampedRightCss * zoomFactor)
|
||||
const bottom = Math.floor(rect.bottom * zoomFactor)
|
||||
const width = right - left
|
||||
const height = bottom - top
|
||||
// A zero-sized rect means the element isn't laid out yet or has been
|
||||
// collapsed behind the chat pane — hide the native view in that case.
|
||||
|
||||
if (width <= 0 || height <= 0) return null
|
||||
const bounds = {
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
return bounds
|
||||
}, [])
|
||||
|
||||
const pushBounds = useCallback((bounds: { x: number; y: number; width: number; height: number }) => {
|
||||
|
|
@ -138,24 +156,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
return bounds
|
||||
}, [measureBounds, pushBounds, setViewVisible])
|
||||
|
||||
// Defensively re-push bounds whenever the underlying page state changes.
|
||||
// Electron's WebContentsView can drop its laid-out rect on navigation,
|
||||
// and even though main re-applies on the same events, pushing from the
|
||||
// renderer ensures we use the *current* layout (in case the chat sidebar
|
||||
// or window resized while the page was loading).
|
||||
useEffect(() => {
|
||||
syncView()
|
||||
}, [state.url, state.loading, syncView])
|
||||
}, [activeTab?.id, activeTab?.loading, activeTab?.url, syncView])
|
||||
|
||||
// ── Visibility lifecycle ──────────────────────────────────────────────────
|
||||
// Order matters: push bounds FIRST so the WCV is created at the right
|
||||
// position, THEN setVisible. Otherwise the view gets attached at stale
|
||||
// (or zero) cached bounds and visually spills until the next bounds push.
|
||||
//
|
||||
// We wait one animation frame after mount so React's commit + the browser's
|
||||
// layout pass for any sibling that just mounted (e.g. the chat sidebar) are
|
||||
// both reflected in getBoundingClientRect. A single RAF is enough — by then
|
||||
// the renderer has painted the post-commit DOM at least once.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
|
|
@ -170,21 +174,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
}
|
||||
}, [setViewVisible, syncView])
|
||||
|
||||
// Ongoing bounds tracking. Observes everything that can move our viewport:
|
||||
// - the viewport div itself (local size changes)
|
||||
// - the SidebarInset ancestor (changes when chat sidebar mounts/resizes)
|
||||
// - the document root (window resize, devicePixelRatio changes)
|
||||
//
|
||||
// ResizeObserver fires after layout but before paint, so by the time we
|
||||
// call pushBounds the rect is current. Multiple observers may fire in the
|
||||
// same frame — RAF-coalesce them so we only post one IPC per frame.
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
|
||||
// The sidebar-inset main element is the immediate flex parent whose
|
||||
// width shrinks when a sibling appears. Walking up the tree is more
|
||||
// robust than passing a ref through props.
|
||||
const sidebarInset = el.closest<HTMLElement>('[data-slot="sidebar-inset"]')
|
||||
const chatSidebar = el.ownerDocument.querySelector<HTMLElement>('[data-chat-sidebar-root]')
|
||||
const documentElement = el.ownerDocument.documentElement
|
||||
|
|
@ -210,7 +203,23 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
}
|
||||
}, [syncView])
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
const handleNewTab = useCallback(() => {
|
||||
void window.ipc.invoke('browser:newTab', {}).then((res) => {
|
||||
const result = res as { ok: boolean; error?: string }
|
||||
if (!result.ok && result.error) {
|
||||
console.error('browser:newTab failed', result.error)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSwitchTab = useCallback((tabId: string) => {
|
||||
void window.ipc.invoke('browser:switchTab', { tabId })
|
||||
}, [])
|
||||
|
||||
const handleCloseTab = useCallback((tabId: string) => {
|
||||
void window.ipc.invoke('browser:closeTab', { tabId })
|
||||
}, [])
|
||||
|
||||
const handleSubmitAddress = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = addressValue.trim()
|
||||
|
|
@ -236,10 +245,27 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
|
||||
>
|
||||
{/* Chrome row: back / forward / reload / address bar */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div className="flex h-9 shrink-0 items-stretch border-b border-border bg-sidebar">
|
||||
<TabBar
|
||||
tabs={state.tabs}
|
||||
activeTabId={state.activeTabId ?? ''}
|
||||
getTabTitle={getBrowserTabTitle}
|
||||
getTabId={(tab) => tab.id}
|
||||
onSwitchTab={handleSwitchTab}
|
||||
onCloseTab={handleCloseTab}
|
||||
layout="scroll"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewTab}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="New browser tab"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-2"
|
||||
style={{ minHeight: CHROME_HEIGHT }}
|
||||
|
|
@ -247,10 +273,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
disabled={!state.canGoBack}
|
||||
disabled={!activeTab?.canGoBack}
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||
state.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
activeTab?.canGoBack ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
)}
|
||||
aria-label="Back"
|
||||
>
|
||||
|
|
@ -259,10 +285,10 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleForward}
|
||||
disabled={!state.canGoForward}
|
||||
disabled={!activeTab?.canGoForward}
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||
state.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
activeTab?.canGoForward ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
)}
|
||||
aria-label="Forward"
|
||||
>
|
||||
|
|
@ -271,10 +297,14 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleReload}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
disabled={!activeTab}
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors',
|
||||
activeTab ? 'hover:bg-accent hover:text-foreground' : 'opacity-40',
|
||||
)}
|
||||
aria-label="Reload"
|
||||
>
|
||||
{state.loading ? (
|
||||
{activeTab?.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-4" />
|
||||
|
|
@ -291,7 +321,7 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
}}
|
||||
onBlur={() => {
|
||||
addressFocusedRef.current = false
|
||||
setAddressValue(state.url)
|
||||
setAddressValue(activeTab?.url ?? '')
|
||||
}}
|
||||
placeholder="Enter URL or search..."
|
||||
className={cn(
|
||||
|
|
@ -304,11 +334,6 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
autoCapitalize="off"
|
||||
/>
|
||||
</form>
|
||||
{state.title && (
|
||||
<div className="hidden max-w-[220px] shrink-0 truncate pl-2 text-xs text-muted-foreground sm:block">
|
||||
{state.title}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
|
@ -319,9 +344,6 @@ export function BrowserPane({ onClose }: BrowserPaneProps) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Viewport placeholder — the WebContentsView is layered on top of this
|
||||
area from the main process. The div itself stays transparent so the
|
||||
user sees the web page, not a React element. */}
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="relative min-h-0 min-w-0 flex-1"
|
||||
|
|
|
|||
|
|
@ -640,6 +640,34 @@ const ipcSchemas = {
|
|||
req: z.object({ visible: z.boolean() }),
|
||||
res: z.object({ ok: z.literal(true) }),
|
||||
},
|
||||
'browser:newTab': {
|
||||
req: z.object({
|
||||
url: z.string().min(1).refine(
|
||||
(u) => {
|
||||
const lower = u.trim().toLowerCase();
|
||||
if (lower.startsWith('javascript:')) return false;
|
||||
if (lower.startsWith('file://')) return false;
|
||||
if (lower.startsWith('chrome://')) return false;
|
||||
if (lower.startsWith('chrome-extension://')) return false;
|
||||
return true;
|
||||
},
|
||||
{ message: 'Unsafe URL scheme' },
|
||||
).optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
tabId: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'browser:switchTab': {
|
||||
req: z.object({ tabId: z.string().min(1) }),
|
||||
res: z.object({ ok: z.boolean() }),
|
||||
},
|
||||
'browser:closeTab': {
|
||||
req: z.object({ tabId: z.string().min(1) }),
|
||||
res: z.object({ ok: z.boolean() }),
|
||||
},
|
||||
'browser:navigate': {
|
||||
req: z.object({
|
||||
url: z.string().min(1).refine(
|
||||
|
|
@ -674,20 +702,28 @@ const ipcSchemas = {
|
|||
'browser:getState': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loading: z.boolean(),
|
||||
activeTabId: z.string().nullable(),
|
||||
tabs: z.array(z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loading: z.boolean(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
'browser:didUpdateState': {
|
||||
req: z.object({
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loading: z.boolean(),
|
||||
activeTabId: z.string().nullable(),
|
||||
tabs: z.array(z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
canGoBack: z.boolean(),
|
||||
canGoForward: z.boolean(),
|
||||
loading: z.boolean(),
|
||||
})),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue