diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts new file mode 100644 index 00000000000..3776ecd2197 --- /dev/null +++ b/src/main/browser/browser-manager.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + shellOpenExternalMock, + menuBuildFromTemplateMock, + guestOffMock, + guestOnMock, + guestSetBackgroundThrottlingMock, + guestSetWindowOpenHandlerMock, + guestOpenDevToolsMock, + webContentsFromIdMock +} = vi.hoisted(() => ({ + shellOpenExternalMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn() +})) + +vi.mock('electron', () => ({ + clipboard: { writeText: vi.fn() }, + shell: { openExternal: shellOpenExternalMock }, + Menu: { + buildFromTemplate: menuBuildFromTemplateMock + }, + webContents: { + fromId: webContentsFromIdMock + } +})) + +import { browserManager } from './browser-manager' + +describe('browserManager', () => { + beforeEach(() => { + shellOpenExternalMock.mockReset() + menuBuildFromTemplateMock.mockReset() + guestOffMock.mockReset() + guestOnMock.mockReset() + guestSetBackgroundThrottlingMock.mockReset() + guestSetWindowOpenHandlerMock.mockReset() + guestOpenDevToolsMock.mockReset() + webContentsFromIdMock.mockReset() + browserManager.unregisterAll() + }) + + it('validates popup URLs before opening externally', () => { + const guest = { + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { action: 'deny' } + + expect(handler({ url: 'localhost:3000' })).toEqual({ action: 'deny' }) + expect(handler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + + expect(shellOpenExternalMock).toHaveBeenCalledTimes(1) + expect(shellOpenExternalMock).toHaveBeenCalledWith('http://localhost:3000/') + }) + + it('blocks non-web guest navigations after attach', () => { + const guest = { + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + + const willNavigateHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'will-navigate' + )?.[1] as ((event: { preventDefault: () => void }, url: string) => void) | undefined + + expect(willNavigateHandler).toBeTypeOf('function') + const preventDefault = vi.fn() + willNavigateHandler?.({ preventDefault }, 'file:///etc/passwd') + expect(preventDefault).toHaveBeenCalledTimes(1) + }) + + it('unregisterAll clears tracked guests and context-menu listeners', () => { + const guest = { + id: 101, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ browserTabId: 'browser-1', webContentsId: 101 }) + browserManager.attachGuestPolicies({ ...guest, id: 102 } as never) + browserManager.registerGuest({ browserTabId: 'browser-2', webContentsId: 102 }) + + browserManager.unregisterAll() + + expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull() + expect(browserManager.getGuestWebContentsId('browser-2')).toBeNull() + expect(guestOffMock).toHaveBeenCalled() + }) + + it('rejects non-webview guest types to prevent privilege escalation', () => { + // A compromised renderer could send the main window's own webContentsId. + // registerGuest must reject it because getType() would return 'window', + // not 'webview'. + const mainWindowContents = { + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(mainWindowContents) + + browserManager.registerGuest({ browserTabId: 'browser-evil', webContentsId: 1 }) + + // The guest should NOT be registered + expect(browserManager.getGuestWebContentsId('browser-evil')).toBeNull() + // setWindowOpenHandler must NOT have been called on the main window's webContents + expect(guestSetWindowOpenHandlerMock).not.toHaveBeenCalled() + }) + + it('rejects registration for guests that never received attach-time policy wiring', () => { + const guest = { + id: 777, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.registerGuest({ browserTabId: 'browser-1', webContentsId: 777 }) + + expect(browserManager.getGuestWebContentsId('browser-1')).toBeNull() + expect(menuBuildFromTemplateMock).not.toHaveBeenCalled() + }) + + it('does not duplicate guest policy listeners when attach is reported twice', () => { + const guest = { + id: 303, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + + browserManager.attachGuestPolicies(guest as never) + browserManager.attachGuestPolicies(guest as never) + + expect(guestSetBackgroundThrottlingMock).toHaveBeenCalledTimes(1) + expect(guestSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) + expect(guestOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) + expect(guestOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) + }) +}) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts new file mode 100644 index 00000000000..5c4a4a7e9cb --- /dev/null +++ b/src/main/browser/browser-manager.ts @@ -0,0 +1,283 @@ +import { clipboard, Menu, shell, webContents } from 'electron' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl +} from '../../shared/browser-url' + +export type BrowserGuestRegistration = { + browserTabId: string + webContentsId: number + rendererWebContentsId: number +} + +class BrowserManager { + private readonly webContentsIdByTabId = new Map() + private readonly rendererWebContentsIdByTabId = new Map() + private readonly contextMenuCleanupByTabId = new Map void>() + private readonly policyAttachedGuestIds = new Set() + private readonly pendingLoadFailuresByGuestId = new Map< + number, + { code: number; description: string; validatedUrl: string } + >() + + private openValidatedExternal(rawUrl: string): void { + const externalUrl = normalizeExternalBrowserUrl(rawUrl) + if (externalUrl) { + void shell.openExternal(externalUrl) + } + } + + attachGuestPolicies(guest: Electron.WebContents): void { + if (this.policyAttachedGuestIds.has(guest.id)) { + return + } + this.policyAttachedGuestIds.add(guest.id) + guest.setBackgroundThrottling(true) + guest.setWindowOpenHandler(({ url }) => { + // Why: popup-capable guests are required for OAuth and target=_blank + // flows, but Orca still does not host child windows itself. Convert those + // attempts into a controlled external-open path instead of letting them + // silently fail or spawn unmanaged windows. + this.openValidatedExternal(url) + return { action: 'deny' } + }) + + const navigationGuard = (event: Electron.Event, url: string): void => { + if (!normalizeBrowserNavigationUrl(url)) { + // Why: `will-attach-webview` only validates the initial src. Main must + // keep enforcing the same allowlist for later guest navigations too. + event.preventDefault() + } + } + + guest.on('will-navigate', navigationGuard) + guest.on('will-redirect', navigationGuard) + guest.on( + 'did-fail-load', + ( + _event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => { + if (!isMainFrame || errorCode === -3) { + return + } + this.forwardOrQueueGuestLoadFailure(guest.id, { + code: errorCode, + description: errorDescription || 'This site could not be reached.', + validatedUrl: validatedURL || guest.getURL() || 'about:blank' + }) + } + ) + } + + registerGuest({ + browserTabId, + webContentsId, + rendererWebContentsId + }: BrowserGuestRegistration): void { + const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return + } + + // Why: the renderer sends webContentsId, which we must not blindly trust. + // A compromised renderer could send the main window's own webContentsId, + // causing us to overwrite its setWindowOpenHandler or attach unintended + // context menus. Only accept genuine webview guest surfaces. + if (guest.getType() !== 'webview') { + return + } + if (!this.policyAttachedGuestIds.has(webContentsId)) { + // Why: renderer registration is only the second half of the guest setup. + // Main must only trust guests that already passed attach-time policy + // installation; otherwise a trusted renderer could point us at some other + // arbitrary webview and bypass the intended host-window attach boundary. + return + } + + this.webContentsIdByTabId.set(browserTabId, webContentsId) + this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) + + this.setupContextMenu(browserTabId, guest) + this.flushPendingLoadFailure(browserTabId, webContentsId) + } + + unregisterGuest(browserTabId: string): void { + const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (cleanup) { + cleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + this.webContentsIdByTabId.delete(browserTabId) + this.rendererWebContentsIdByTabId.delete(browserTabId) + } + + unregisterAll(): void { + for (const browserTabId of this.webContentsIdByTabId.keys()) { + this.unregisterGuest(browserTabId) + } + this.policyAttachedGuestIds.clear() + this.pendingLoadFailuresByGuestId.clear() + } + + getGuestWebContentsId(browserTabId: string): number | null { + return this.webContentsIdByTabId.get(browserTabId) ?? null + } + + // Why: guest browser surfaces are intentionally isolated from Orca's preload + // bridge, so renderer code cannot directly call Electron WebContents APIs on + // them. Main owns the devtools escape hatch and only after tab→guest lookup. + async openDevTools(browserTabId: string): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + this.webContentsIdByTabId.delete(browserTabId) + return false + } + guest.openDevTools({ mode: 'detach' }) + return true + } + + private setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { + const handler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => { + const pageUrl = guest.getURL() + const linkUrl = params.linkURL || '' + + const template: Electron.MenuItemConstructorOptions[] = [] + + if (linkUrl) { + const externalLinkUrl = normalizeExternalBrowserUrl(linkUrl) + template.push( + { + label: 'Open Link In Default Browser', + enabled: Boolean(externalLinkUrl && externalLinkUrl !== 'about:blank'), + click: () => { + this.openValidatedExternal(linkUrl) + } + }, + { + label: 'Copy Link Address', + click: () => { + clipboard.writeText(linkUrl) + } + }, + { type: 'separator' } + ) + } + + const externalPageUrl = normalizeExternalBrowserUrl(pageUrl) + + template.push( + { + label: 'Back', + enabled: guest.canGoBack(), + click: () => guest.goBack() + }, + { + label: 'Forward', + enabled: guest.canGoForward(), + click: () => guest.goForward() + }, + { + label: 'Reload', + click: () => guest.reload() + }, + { type: 'separator' }, + { + label: 'Open Page In Default Browser', + enabled: Boolean(externalPageUrl && externalPageUrl !== 'about:blank'), + click: () => { + this.openValidatedExternal(pageUrl) + } + }, + { + label: 'Copy Page URL', + enabled: Boolean(pageUrl), + click: () => { + clipboard.writeText(pageUrl) + } + }, + { type: 'separator' }, + { + label: 'Inspect Page', + click: () => { + void this.openDevTools(browserTabId) + } + } + ) + + Menu.buildFromTemplate(template).popup() + } + + guest.on('context-menu', handler) + this.contextMenuCleanupByTabId.set(browserTabId, () => { + try { + guest.off('context-menu', handler) + } catch { + // Why: browser tabs can outlive the guest webContents briefly during + // teardown. Cleanup should be best-effort instead of throwing while the + // IDE is closing a tab. + } + }) + } + + private forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const browserTabId = [...this.webContentsIdByTabId.entries()].find( + ([, webContentsId]) => webContentsId === guestWebContentsId + )?.[0] + if (!browserTabId) { + // Why: some localhost failures happen before the renderer finishes + // registering which tab owns this guest. Queue the failure by guest ID so + // registerGuest can replay it instead of silently losing the error state. + this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) + return + } + this.sendGuestLoadFailure(browserTabId, loadError) + } + + private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) + if (!pending) { + return + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.sendGuestLoadFailure(browserTabId, pending) + } + + private sendGuestLoadFailure( + browserTabId: string, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) + if (!rendererWebContentsId) { + return + } + + const renderer = webContents.fromId(rendererWebContentsId) + if (!renderer || renderer.isDestroyed()) { + return + } + + renderer.send('browser:guest-load-failed', { + browserTabId, + loadError + }) + } +} + +export const browserManager = new BrowserManager() diff --git a/src/main/index.ts b/src/main/index.ts index 8221ddfec57..2f06f048eb4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,8 +51,15 @@ function openMainWindow(): BrowserWindow { if (!runtime) { throw new Error('Runtime must be initialized before opening the main window') } + if (!stats) { + throw new Error('Stats must be initialized before opening the main window') + } + if (!claudeUsage) { + throw new Error('Claude usage store must be initialized before opening the main window') + } const window = createMainWindow(store) + registerCoreHandlers(store, runtime, stats, claudeUsage, window.webContents.id) attachMainWindowServices(window, store, runtime) window.on('closed', () => { if (mainWindow === window) { @@ -97,7 +104,6 @@ app.whenReady().then(async () => { mainWindow?.webContents.send('terminal:zoom', 'reset') } }) - registerCoreHandlers(store, runtime, stats, claudeUsage) runtimeRpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: app.getPath('userData') diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts new file mode 100644 index 00000000000..f67bf7c92f0 --- /dev/null +++ b/src/main/ipc/browser.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { removeHandlerMock, handleMock, registerGuestMock, unregisterGuestMock, openDevToolsMock } = + vi.hoisted(() => ({ + removeHandlerMock: vi.fn(), + handleMock: vi.fn(), + registerGuestMock: vi.fn(), + unregisterGuestMock: vi.fn(), + openDevToolsMock: vi.fn().mockResolvedValue(true) + })) + +vi.mock('electron', () => ({ + ipcMain: { + removeHandler: removeHandlerMock, + handle: handleMock + } +})) + +vi.mock('../browser/browser-manager', () => ({ + browserManager: { + registerGuest: registerGuestMock, + unregisterGuest: unregisterGuestMock, + openDevTools: openDevToolsMock + } +})) + +import { registerBrowserHandlers } from './browser' + +describe('registerBrowserHandlers', () => { + beforeEach(() => { + removeHandlerMock.mockReset() + handleMock.mockReset() + registerGuestMock.mockReset() + unregisterGuestMock.mockReset() + openDevToolsMock.mockReset() + openDevToolsMock.mockResolvedValue(true) + }) + + it('rejects non-window callers', async () => { + registerBrowserHandlers() + + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => boolean + + const result = registerHandler( + { + sender: { + isDestroyed: () => false, + getType: () => 'webview', + getURL: () => 'http://localhost:5173/' + } as Electron.WebContents + }, + { browserTabId: 'browser-1', webContentsId: 101 } + ) + + expect(result).toBe(false) + expect(registerGuestMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts new file mode 100644 index 00000000000..1d412fabc3e --- /dev/null +++ b/src/main/ipc/browser.ts @@ -0,0 +1,63 @@ +import { ipcMain } from 'electron' +import { browserManager } from '../browser/browser-manager' + +let trustedBrowserRendererWebContentsId: number | null = null + +export function setTrustedBrowserRendererWebContentsId(webContentsId: number | null): void { + trustedBrowserRendererWebContentsId = webContentsId +} + +function isTrustedBrowserRenderer(sender: Electron.WebContents): boolean { + if (sender.isDestroyed() || sender.getType() !== 'window') { + return false + } + if (trustedBrowserRendererWebContentsId != null) { + return sender.id === trustedBrowserRendererWebContentsId + } + + const senderUrl = sender.getURL() + if (process.env.ELECTRON_RENDERER_URL) { + try { + return new URL(senderUrl).origin === new URL(process.env.ELECTRON_RENDERER_URL).origin + } catch { + return false + } + } + + return senderUrl.startsWith('file://') +} + +export function registerBrowserHandlers(): void { + ipcMain.removeHandler('browser:registerGuest') + ipcMain.removeHandler('browser:unregisterGuest') + ipcMain.removeHandler('browser:openDevTools') + + ipcMain.handle( + 'browser:registerGuest', + (event, args: { browserTabId: string; webContentsId: number }) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + browserManager.registerGuest({ + ...args, + rendererWebContentsId: event.sender.id + }) + return true + } + ) + + ipcMain.handle('browser:unregisterGuest', (event, args: { browserTabId: string }) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + browserManager.unregisterGuest(args.browserTabId) + return true + }) + + ipcMain.handle('browser:openDevTools', (event, args: { browserTabId: string }) => { + if (!isTrustedBrowserRenderer(event.sender)) { + return false + } + return browserManager.openDevTools(args.browserTabId) + }) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index de754b73002..0203331adfe 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -14,7 +14,9 @@ const { registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerClipboardHandlersMock, - registerUpdaterHandlersMock + registerUpdaterHandlersMock, + registerBrowserHandlersMock, + setTrustedBrowserRendererWebContentsIdMock } = vi.hoisted(() => ({ registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), @@ -29,7 +31,9 @@ const { registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), - registerUpdaterHandlersMock: vi.fn() + registerUpdaterHandlersMock: vi.fn(), + registerBrowserHandlersMock: vi.fn(), + setTrustedBrowserRendererWebContentsIdMock: vi.fn() })) vi.mock('./cli', () => ({ @@ -85,6 +89,11 @@ vi.mock('../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) +vi.mock('./browser', () => ({ + registerBrowserHandlers: registerBrowserHandlersMock, + setTrustedBrowserRendererWebContentsId: setTrustedBrowserRendererWebContentsIdMock +})) + import { registerCoreHandlers } from './register-core-handlers' describe('registerCoreHandlers', () => { @@ -103,6 +112,8 @@ describe('registerCoreHandlers', () => { registerRuntimeHandlersMock.mockReset() registerClipboardHandlersMock.mockReset() registerUpdaterHandlersMock.mockReset() + registerBrowserHandlersMock.mockReset() + setTrustedBrowserRendererWebContentsIdMock.mockReset() }) it('passes the store through to handler registrars that need it', () => { @@ -127,5 +138,7 @@ describe('registerCoreHandlers', () => { expect(registerShellHandlersMock).toHaveBeenCalled() expect(registerClipboardHandlersMock).toHaveBeenCalled() expect(registerUpdaterHandlersMock).toHaveBeenCalled() + expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null) + expect(registerBrowserHandlersMock).toHaveBeenCalled() }) }) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index c5b81aa17f2..7f5276226f6 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -9,8 +9,10 @@ import { registerGitHubHandlers } from './github' import { registerStatsHandlers } from './stats' import { registerRuntimeHandlers } from './runtime' import { registerNotificationHandlers } from './notifications' +import { setTrustedBrowserRendererWebContentsId } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' +import { registerBrowserHandlers } from './browser' import { registerShellHandlers } from './shell' import { registerUIHandlers } from './ui' import { warmSystemFontFamilies } from '../system-fonts' @@ -24,8 +26,10 @@ export function registerCoreHandlers( store: Store, runtime: OrcaRuntimeService, stats: StatsCollector, - claudeUsage: ClaudeUsageStore + claudeUsage: ClaudeUsageStore, + mainWindowWebContentsId: number | null = null ): void { + setTrustedBrowserRendererWebContentsId(mainWindowWebContentsId) registerCliHandlers() registerPreflightHandlers() registerClaudeUsageHandlers(claudeUsage) @@ -33,6 +37,7 @@ export function registerCoreHandlers( registerStatsHandlers(stats) registerNotificationHandlers(store) registerSettingsHandlers(store) + registerBrowserHandlers() registerShellHandlers() registerSessionHandlers(store) registerUIHandlers(store) diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 48a26365ef4..f08edc0ac36 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -4,23 +4,34 @@ const { onMock, removeAllListenersMock, setPermissionRequestHandlerMock, + setPermissionCheckHandlerMock, + setDisplayMediaRequestHandlerMock, registerRepoHandlersMock, registerWorktreeHandlersMock, registerPtyHandlersMock, - setupAutoUpdaterMock + setupAutoUpdaterMock, + sessionFromPartitionMock, + browserManagerUnregisterAllMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), setPermissionRequestHandlerMock: vi.fn(), + setPermissionCheckHandlerMock: vi.fn(), + setDisplayMediaRequestHandlerMock: vi.fn(), registerRepoHandlersMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), - setupAutoUpdaterMock: vi.fn() + setupAutoUpdaterMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + browserManagerUnregisterAllMock: vi.fn() })) vi.mock('electron', () => ({ app: {}, clipboard: {}, + session: { + fromPartition: sessionFromPartitionMock + }, ipcMain: { on: onMock, removeAllListeners: removeAllListenersMock, @@ -41,6 +52,12 @@ vi.mock('../ipc/pty', () => ({ registerPtyHandlers: registerPtyHandlersMock })) +vi.mock('../browser/browser-manager', () => ({ + browserManager: { + unregisterAll: browserManagerUnregisterAllMock + } +})) + vi.mock('../updater', () => ({ checkForUpdates: vi.fn(), getUpdateStatus: vi.fn(), @@ -55,10 +72,20 @@ describe('attachMainWindowServices', () => { onMock.mockReset() removeAllListenersMock.mockReset() setPermissionRequestHandlerMock.mockReset() + setPermissionCheckHandlerMock.mockReset() + setDisplayMediaRequestHandlerMock.mockReset() registerRepoHandlersMock.mockReset() registerWorktreeHandlersMock.mockReset() registerPtyHandlersMock.mockReset() setupAutoUpdaterMock.mockReset() + sessionFromPartitionMock.mockReset() + browserManagerUnregisterAllMock.mockReset() + sessionFromPartitionMock.mockReturnValue({ + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, + on: vi.fn() + }) }) it('only allows the explicit permission allowlist', () => { @@ -81,7 +108,7 @@ describe('attachMainWindowServices', () => { attachMainWindowServices(mainWindow as never, store as never, runtime as never) - expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(1) + expect(setPermissionRequestHandlerMock).toHaveBeenCalledTimes(2) const permissionHandler = setPermissionRequestHandlerMock.mock.calls[0][0] const callback = vi.fn() @@ -93,6 +120,104 @@ describe('attachMainWindowServices', () => { expect(callback.mock.calls).toEqual([[true], [true], [true], [false]]) }) + it('denies browser-session permissions, display capture, and downloads by default', () => { + const browserSessionOnMock = vi.fn() + sessionFromPartitionMock.mockReturnValue({ + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, + on: browserSessionOnMock + }) + + const mainWindowOnMock = vi.fn() + const mainWindow = { + on: mainWindowOnMock, + webContents: { + on: vi.fn(), + session: { + setPermissionRequestHandler: setPermissionRequestHandlerMock + } + } + } + const store = { flush: vi.fn() } + const runtime = { + attachWindow: vi.fn(), + setNotifier: vi.fn(), + markRendererReloading: vi.fn(), + markGraphUnavailable: vi.fn() + } + + attachMainWindowServices(mainWindow as never, store as never, runtime as never) + + const browserPermissionHandler = setPermissionRequestHandlerMock.mock.calls[1][0] as ( + wc: unknown, + permission: string, + callback: (allowed: boolean) => void + ) => void + const permissionCallback = vi.fn() + browserPermissionHandler(null, 'fullscreen', permissionCallback) + browserPermissionHandler(null, 'media', permissionCallback) + + expect(permissionCallback.mock.calls).toEqual([[true], [false]]) + + const browserPermissionCheckHandler = setPermissionCheckHandlerMock.mock.calls[0][0] as ( + wc: unknown, + permission: string + ) => boolean + expect(browserPermissionCheckHandler(null, 'fullscreen')).toBe(true) + expect(browserPermissionCheckHandler(null, 'notifications')).toBe(false) + + const displayMediaHandler = setDisplayMediaRequestHandlerMock.mock.calls[0][0] as ( + request: unknown, + callback: (streams: { video: null; audio: null }) => void + ) => void + const displayCallback = vi.fn() + displayMediaHandler(null, displayCallback) + expect(displayCallback).toHaveBeenCalledWith({ video: undefined, audio: undefined }) + + const willDownloadHandler = browserSessionOnMock.mock.calls.find( + ([eventName]) => eventName === 'will-download' + )?.[1] as (event: { preventDefault: () => void }) => void + const preventDefault = vi.fn() + willDownloadHandler({ preventDefault }) + expect(preventDefault).toHaveBeenCalledTimes(1) + }) + + it('clears browser guest registrations when the main window closes', () => { + sessionFromPartitionMock.mockReturnValue({ + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock, + setDisplayMediaRequestHandler: setDisplayMediaRequestHandlerMock, + on: vi.fn() + }) + const mainWindowOnMock = vi.fn() + const mainWindow = { + on: mainWindowOnMock, + webContents: { + on: vi.fn(), + session: { + setPermissionRequestHandler: setPermissionRequestHandlerMock + } + } + } + const store = { flush: vi.fn() } + const runtime = { + attachWindow: vi.fn(), + setNotifier: vi.fn(), + markRendererReloading: vi.fn(), + markGraphUnavailable: vi.fn() + } + + attachMainWindowServices(mainWindow as never, store as never, runtime as never) + + const closedHandler = mainWindowOnMock.mock.calls + .filter(([event]) => event === 'closed') + .at(-1)?.[1] as (() => void) | undefined + expect(closedHandler).toBeTypeOf('function') + closedHandler?.() + expect(browserManagerUnregisterAllMock).toHaveBeenCalledTimes(1) + }) + it('forwards runtime notifier events to the renderer', () => { const sendMock = vi.fn() const webContentsOnMock = vi.fn() diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index d02c4012d9e..bdc1201a610 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -1,10 +1,12 @@ -import { app, clipboard, ipcMain } from 'electron' +import { app, clipboard, ipcMain, session } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' import type { CreateWorktreeResult } from '../../shared/types' +import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerPtyHandlers } from '../ipc/pty' +import { browserManager } from '../browser/browser-manager' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { checkForUpdatesFromMenu, @@ -38,6 +40,39 @@ export function attachMainWindowServices( callback(allowedPermissions.has(permission)) } ) + + const browserSession = session.fromPartition(ORCA_BROWSER_PARTITION) + browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { + // Why: the in-app browser is for dev previews and lightweight browsing, not + // trusted desktop-app privileges. Denying by default keeps arbitrary sites + // from silently escalating into camera/mic/notification prompts inside Orca. + callback(permission === 'fullscreen') + }) + browserSession.setPermissionCheckHandler((_webContents, permission) => { + return permission === 'fullscreen' + }) + browserSession.setDisplayMediaRequestHandler((_request, callback) => { + // Why: arbitrary sites inside Orca should never be able to capture the + // desktop or application windows until there is explicit product UX for + // selecting a source and surfacing that choice to the user. + // Why: pass undefined (not null) to satisfy Electron's typed callback + // signature while still denying the request. + callback({ video: undefined, audio: undefined }) + }) + browserSession.on('will-download', (event) => { + // Why: browser-tab downloads need explicit product UX before arbitrary sites + // can write files through Orca. Until that exists, cancel downloads instead + // of inheriting Electron's default save behavior invisibly. + event.preventDefault() + }) + + mainWindow.on('closed', () => { + // Why: parked browser webviews can outlive the visible tab body until the + // renderer process exits. Clearing main-owned guest registrations on window + // close prevents stale tab→webContents ids from leaking across app relaunch + // or hot-reload cycles. + browserManager.unregisterAll() + }) } function registerRuntimeWindowLifecycle( diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index caeba78bc2e..e4503320fd7 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { browserWindowMock, openExternalMock } = vi.hoisted(() => ({ +const { browserWindowMock, openExternalMock, attachGuestPoliciesMock } = vi.hoisted(() => ({ browserWindowMock: vi.fn(), - openExternalMock: vi.fn() + openExternalMock: vi.fn(), + attachGuestPoliciesMock: vi.fn() })) vi.mock('electron', () => ({ @@ -24,12 +25,19 @@ vi.mock('../../../resources/icon-dev.png?asset', () => ({ default: 'icon-dev' })) +vi.mock('../browser/browser-manager', () => ({ + browserManager: { + attachGuestPolicies: attachGuestPoliciesMock + } +})) + import { createMainWindow } from './createMainWindow' describe('createMainWindow', () => { beforeEach(() => { browserWindowMock.mockReset() openExternalMock.mockReset() + attachGuestPoliciesMock.mockReset() }) it('enables renderer sandboxing and opens external links safely', () => { @@ -39,6 +47,8 @@ describe('createMainWindow', () => { windowHandlers[event] = handler }), setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), setWindowOpenHandler: vi.fn((handler) => { windowHandlers.windowOpen = handler }), @@ -47,6 +57,11 @@ describe('createMainWindow', () => { const browserWindowInstance = { webContents, on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), maximize: vi.fn(), show: vi.fn(), loadFile: vi.fn(), @@ -70,7 +85,7 @@ describe('createMainWindow', () => { expect(windowHandlers.windowOpen({ url: 'not a url' })).toEqual({ action: 'deny' }) expect(openExternalMock).toHaveBeenCalledTimes(2) - expect(openExternalMock).toHaveBeenCalledWith('https://example.com') + expect(openExternalMock).toHaveBeenCalledWith('https://example.com/') expect(openExternalMock).toHaveBeenCalledWith('http://localhost:3000/') const preventDefault = vi.fn() @@ -95,6 +110,27 @@ describe('createMainWindow', () => { ) expect(fileNavigationPreventDefault).toHaveBeenCalledTimes(1) expect(openExternalMock).toHaveBeenCalledTimes(4) + + const allowBlankEvent = { preventDefault: vi.fn() } + const allowBlankPrefs = { partition: 'persist:orca-browser' } + windowHandlers['will-attach-webview']( + allowBlankEvent as never, + allowBlankPrefs as never, + { src: 'data:text/html,' } as never + ) + expect(allowBlankEvent.preventDefault).not.toHaveBeenCalled() + + const denyInlineHtmlEvent = { preventDefault: vi.fn() } + windowHandlers['will-attach-webview']( + denyInlineHtmlEvent as never, + { partition: 'persist:orca-browser' } as never, + { src: 'data:text/html,' } as never + ) + expect(denyInlineHtmlEvent.preventDefault).toHaveBeenCalledTimes(1) + + const guest = { marker: 'guest' } + windowHandlers['did-attach-webview']({} as never, guest as never) + expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest) }) it('supports all minus key variants for terminal zoom out', () => { @@ -104,12 +140,19 @@ describe('createMainWindow', () => { windowHandlers[event] = handler }), setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() } const browserWindowInstance = { webContents, on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), maximize: vi.fn(), show: vi.fn(), loadFile: vi.fn(), @@ -152,12 +195,19 @@ describe('createMainWindow', () => { windowHandlers[event] = handler }), setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() } const browserWindowInstance = { webContents, on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), maximize: vi.fn(), show: vi.fn(), loadFile: vi.fn(), diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 577c329146c..3a1b6af7f73 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -4,26 +4,12 @@ import { is } from '@electron-toolkit/utils' import icon from '../../../resources/icon.png?asset' import devIcon from '../../../resources/icon-dev.png?asset' import type { Store } from '../persistence' - -const LOCAL_ADDRESS_PATTERN = - /^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:\/.*)?$/i - -function normalizeExternalUrl(rawUrl: string): string | null { - if (LOCAL_ADDRESS_PATTERN.test(rawUrl)) { - try { - return new URL(`http://${rawUrl}`).toString() - } catch { - return null - } - } - - try { - const parsed = new URL(rawUrl) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? rawUrl : null - } catch { - return null - } -} +import { browserManager } from '../browser/browser-manager' +import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl +} from '../../shared/browser-url' function isZoomInShortcut(input: Electron.Input): boolean { return input.key === '=' || input.key === '+' || input.code === 'NumpadAdd' @@ -46,6 +32,23 @@ function isZoomOutShortcut(input: Electron.Input): boolean { ) } +function forceRepaint(window: BrowserWindow): void { + if (window.isDestroyed()) { + return + } + window.webContents.invalidate() + if (window.isMaximized() || window.isFullScreen()) { + return + } + const [width, height] = window.getSize() + window.setSize(width + 1, height) + setTimeout(() => { + if (!window.isDestroyed()) { + window.setSize(width, height) + } + }, 32) +} + export function createMainWindow(store: Store | null): BrowserWindow { const mainWindow = new BrowserWindow({ width: 1200, @@ -60,10 +63,26 @@ export function createMainWindow(store: Store | null): BrowserWindow { icon: is.dev ? devIcon : icon, webPreferences: { preload: join(__dirname, '../preload/index.js'), - sandbox: true + sandbox: true, + webviewTag: true } }) + if (process.platform === 'darwin') { + // Why: persistent parked webviews use separate compositor layers, and on + // recent macOS releases those layers can fail to repaint after occlusion or + // restore. Disabling main-window throttling and forcing a repaint on + // visibility transitions hardens Orca against the same black-surface + // failure mode seen during browser-tab restore and tab switching. + mainWindow.webContents.setBackgroundThrottling(false) + mainWindow.on('restore', () => { + forceRepaint(mainWindow) + }) + mainWindow.on('show', () => { + forceRepaint(mainWindow) + }) + } + mainWindow.webContents.on('dom-ready', () => { mainWindow.webContents.setZoomLevel(store?.getUI().uiZoomLevel ?? 0) }) @@ -82,18 +101,56 @@ export function createMainWindow(store: Store | null): BrowserWindow { }) mainWindow.webContents.setWindowOpenHandler((details) => { - const externalUrl = normalizeExternalUrl(details.url) + const externalUrl = normalizeExternalBrowserUrl(details.url) if (externalUrl) { shell.openExternal(externalUrl) } return { action: 'deny' } }) + mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { + const src = typeof params.src === 'string' ? params.src : '' + const normalizedSrc = normalizeBrowserNavigationUrl(src) + const partition = typeof webPreferences.partition === 'string' ? webPreferences.partition : '' + + // Why: arbitrary sites must stay inside an unprivileged guest surface. We + // fail closed here so a renderer bug cannot smuggle preload, Node, or a + // non-browser partition into the guest and widen the app privilege boundary. + // The one allowed data URL is Orca's inert blank-tab bootstrap page; deny + // every other data URL so the renderer cannot inject arbitrary inline HTML. + if (!normalizedSrc || partition !== ORCA_BROWSER_PARTITION) { + event.preventDefault() + return + } + + delete webPreferences.preload + // Why: older Electron builds expose preloadURL alongside preload; delete + // both so the guest surface cannot inherit the main preload bridge. + delete (webPreferences as Record).preloadURL + webPreferences.nodeIntegration = false + webPreferences.nodeIntegrationInSubFrames = false + webPreferences.enableBlinkFeatures = '' + webPreferences.disableBlinkFeatures = '' + webPreferences.webSecurity = true + webPreferences.allowRunningInsecureContent = false + webPreferences.contextIsolation = true + webPreferences.sandbox = true + webPreferences.partition = ORCA_BROWSER_PARTITION + }) + + mainWindow.webContents.on('did-attach-webview', (_event, guest) => { + // Why: popup and navigation policy must attach as soon as Chromium creates + // the guest webContents. Waiting until renderer-driven registration leaves + // a race where target=_blank or early redirects can bypass Orca's intended + // fallback behavior. + browserManager.attachGuestPolicies(guest) + }) + // Block ALL in-window navigations to prevent remote pages from inheriting // the privileged preload bridge (PTY, filesystem, etc.). // In dev mode, allow navigations to the local dev server (e.g. HMR reloads). mainWindow.webContents.on('will-navigate', (event, url) => { - const externalUrl = normalizeExternalUrl(url) + const externalUrl = normalizeExternalBrowserUrl(url) if (externalUrl) { const target = new URL(externalUrl) diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts new file mode 100644 index 00000000000..c614d481b18 --- /dev/null +++ b/src/preload/api-types.d.ts @@ -0,0 +1,294 @@ +import type { + BrowserLoadError, + CreateWorktreeResult, + DirEntry, + GlobalSettings, + GitBranchCompareResult, + GitConflictOperation, + GitDiffResult, + GitStatusEntry, + IssueInfo, + NotificationDispatchRequest, + NotificationDispatchResult, + OrcaHooks, + PersistedUIState, + PRCheckDetail, + PRComment, + PRInfo, + Repo, + SearchOptions, + SearchResult, + StatsSummary, + UpdateStatus, + Worktree, + WorktreeMeta, + WorktreeSetupLaunch, + WorkspaceSessionState +} from '../../shared/types' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types' +import type { + ClaudeUsageBreakdownKind, + ClaudeUsageBreakdownRow, + ClaudeUsageDailyPoint, + ClaudeUsageRange, + ClaudeUsageScanState, + ClaudeUsageScope, + ClaudeUsageSessionRow, + ClaudeUsageSummary +} from '../../shared/claude-usage-types' + +export type BrowserApi = { + registerGuest: (args: { browserTabId: string; webContentsId: number }) => Promise + unregisterGuest: (args: { browserTabId: string }) => Promise + openDevTools: (args: { browserTabId: string }) => Promise + onGuestLoadFailed: ( + callback: (args: { browserTabId: string; loadError: BrowserLoadError }) => void + ) => () => void +} + +export type PreflightStatus = { + git: { installed: boolean } + gh: { installed: boolean; authenticated: boolean } +} + +export type PreflightApi = { + check: (args?: { force?: boolean }) => Promise +} + +export type StatsApi = { + getSummary: () => Promise +} + +export type ClaudeUsageApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSummary: (args: { + scope: ClaudeUsageScope + range: ClaudeUsageRange + }) => Promise + getDaily: (args: { + scope: ClaudeUsageScope + range: ClaudeUsageRange + }) => Promise + getBreakdown: (args: { + scope: ClaudeUsageScope + range: ClaudeUsageRange + kind: ClaudeUsageBreakdownKind + }) => Promise + getRecentSessions: (args: { + scope: ClaudeUsageScope + range: ClaudeUsageRange + limit?: number + }) => Promise +} + +export type PreloadApi = { + repos: { + list: () => Promise + add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise + remove: (args: { repoId: string }) => Promise + update: (args: { + repoId: string + updates: Partial< + Pick + > + }) => Promise + pickFolder: () => Promise + pickDirectory: () => Promise + clone: (args: { url: string; destination: string }) => Promise + cloneAbort: () => Promise + onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void + getGitUsername: (args: { repoId: string }) => Promise + getBaseRefDefault: (args: { repoId: string }) => Promise + searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise + onChanged: (callback: () => void) => () => void + } + worktrees: { + list: (args: { repoId: string }) => Promise + listAll: () => Promise + create: (args: { + repoId: string + name: string + baseBranch?: string + setupDecision?: 'inherit' | 'run' | 'skip' + }) => Promise + remove: (args: { worktreeId: string; force?: boolean }) => Promise + updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise + persistSortOrder: (args: { orderedIds: string[] }) => Promise + onChanged: (callback: (data: { repoId: string }) => void) => () => void + } + pty: { + spawn: (opts: { + cols: number + rows: number + cwd?: string + env?: Record + }) => Promise<{ id: string }> + write: (id: string, data: string) => void + resize: (id: string, cols: number, rows: number) => void + kill: (id: string) => Promise + hasChildProcesses: (id: string) => Promise + onData: (callback: (data: { id: string; data: string }) => void) => () => void + onExit: (callback: (data: { id: string; code: number }) => void) => () => void + } + gh: { + prForBranch: (args: { repoPath: string; branch: string }) => Promise + issue: (args: { repoPath: string; number: number }) => Promise + listIssues: (args: { repoPath: string; limit?: number }) => Promise + prChecks: (args: { + repoPath: string + prNumber: number + headSha?: string + noCache?: boolean + }) => Promise + prComments: (args: { + repoPath: string + prNumber: number + noCache?: boolean + }) => Promise + resolveReviewThread: (args: { + repoPath: string + threadId: string + resolve: boolean + }) => Promise + updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise + mergePR: (args: { + repoPath: string + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + }) => Promise<{ ok: true } | { ok: false; error: string }> + checkOrcaStarred: () => Promise + starOrca: () => Promise + } + settings: { + get: () => Promise + set: (args: Partial) => Promise + listFonts: () => Promise + } + cli: { + getInstallStatus: () => Promise + install: () => Promise + remove: () => Promise + } + preflight: PreflightApi + notifications: { + dispatch: (args: NotificationDispatchRequest) => Promise + openSystemSettings: () => Promise + } + shell: { + openPath: (path: string) => Promise + openUrl: (url: string) => Promise + openFilePath: (path: string) => Promise + openFileUri: (uri: string) => Promise + pathExists: (path: string) => Promise + pickImage: () => Promise + copyFile: (args: { srcPath: string; destPath: string }) => Promise + } + browser: BrowserApi + hooks: { + check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }> + } + cache: { + getGitHub: () => Promise<{ + pr: Record + issue: Record + }> + setGitHub: (args: { + cache: { + pr: Record + issue: Record + } + }) => Promise + } + session: { + get: () => Promise + set: (args: WorkspaceSessionState) => Promise + setSync: (args: WorkspaceSessionState) => void + } + updater: { + getVersion: () => Promise + getStatus: () => Promise + check: () => Promise + download: () => Promise + quitAndInstall: () => Promise + onStatus: (callback: (status: UpdateStatus) => void) => () => void + } + stats: StatsApi + claudeUsage: ClaudeUsageApi + fs: { + readDir: (args: { dirPath: string }) => Promise + readFile: (args: { + filePath: string + }) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> + writeFile: (args: { filePath: string; content: string }) => Promise + createFile: (args: { filePath: string }) => Promise + createDir: (args: { dirPath: string }) => Promise + rename: (args: { oldPath: string; newPath: string }) => Promise + deletePath: (args: { targetPath: string }) => Promise + authorizeExternalPath: (args: { targetPath: string }) => Promise + stat: (args: { + filePath: string + }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> + listFiles: (args: { rootPath: string }) => Promise + search: (args: SearchOptions) => Promise + } + git: { + status: (args: { worktreePath: string }) => Promise<{ entries: GitStatusEntry[] }> + conflictOperation: (args: { worktreePath: string }) => Promise + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + }) => Promise + branchCompare: (args: { + worktreePath: string + baseRef: string + }) => Promise + branchDiff: (args: { + worktreePath: string + compare: { + baseRef: string + baseOid: string + headOid: string + mergeBase: string + } + filePath: string + oldPath?: string + }) => Promise + stage: (args: { worktreePath: string; filePath: string }) => Promise + bulkStage: (args: { worktreePath: string; filePaths: string[] }) => Promise + unstage: (args: { worktreePath: string; filePath: string }) => Promise + bulkUnstage: (args: { worktreePath: string; filePaths: string[] }) => Promise + discard: (args: { worktreePath: string; filePath: string }) => Promise + remoteFileUrl: (args: { + worktreePath: string + relativePath: string + line: number + }) => Promise + } + ui: { + get: () => Promise + set: (args: Partial) => Promise + onOpenSettings: (callback: () => void) => () => void + onActivateWorktree: ( + callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void + ) => () => void + onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void + readClipboardText: () => Promise + writeClipboardText: (text: string) => Promise + onFileDrop: ( + callback: (data: { path: string; target: 'editor' | 'terminal' }) => void + ) => () => void + getZoomLevel: () => number + setZoomLevel: (level: number) => void + onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void + onWindowCloseRequested: (callback: () => void) => () => void + confirmWindowClose: () => void + } + runtime: { + syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise + getStatus: () => Promise + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index b58bac53305..d65d1adab7e 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,43 +1,9 @@ import type { ElectronAPI } from '@electron-toolkit/preload' -import type { CliInstallStatus } from '../../shared/cli-install-types' import type { - Repo, - Worktree, - WorktreeMeta, - CreateWorktreeArgs, CreateWorktreeResult, - PRInfo, - PRCheckDetail, - PRComment, - IssueInfo, - GlobalSettings, - NotificationDispatchRequest, - NotificationDispatchResult, - OrcaHooks, - PersistedUIState, - WorkspaceSessionState, - WorktreeSetupLaunch, - UpdateStatus, - DirEntry, - GitBranchCompareResult, - GitConflictOperation, - GitStatusEntry, - GitDiffResult, - SearchOptions, - SearchResult, - StatsSummary + CreateWorktreeArgs } from '../../shared/types' -import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageBreakdownRow, - ClaudeUsageDailyPoint, - ClaudeUsageRange, - ClaudeUsageScanState, - ClaudeUsageScope, - ClaudeUsageSessionRow, - ClaudeUsageSummary -} from '../../shared/claude-usage-types' +import type { PreloadApi } from './api-types' type ReposApi = { list: () => Promise @@ -142,174 +108,10 @@ type ShellApi = { copyFile: (args: { srcPath: string; destPath: string }) => Promise } -type HooksApi = { - check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }> -} - -type CacheApi = { - getGitHub: () => Promise<{ - pr: Record - issue: Record - }> - setGitHub: (args: { - cache: { - pr: Record - issue: Record - } - }) => Promise -} - -type SessionApi = { - get: () => Promise - set: (args: WorkspaceSessionState) => Promise - /** Synchronous session save for beforeunload — blocks until flushed to disk. */ - setSync: (args: WorkspaceSessionState) => void -} - -type UpdaterApi = { - getVersion: () => Promise - getStatus: () => Promise - check: () => Promise - download: () => Promise - quitAndInstall: () => Promise - onStatus: (callback: (status: UpdateStatus) => void) => () => void -} - -type UIApi = { - get: () => Promise - set: (args: Partial) => Promise - onOpenSettings: (callback: () => void) => () => void - onActivateWorktree: ( - callback: (data: { repoId: string; worktreeId: string; setup?: WorktreeSetupLaunch }) => void - ) => () => void - onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void - readClipboardText: () => Promise - writeClipboardText: (text: string) => Promise - onFileDrop: ( - callback: (data: { path: string; target: 'editor' | 'terminal' }) => void - ) => () => void - getZoomLevel: () => number - setZoomLevel: (level: number) => void - onFullscreenChanged: (callback: (isFullScreen: boolean) => void) => () => void - onWindowCloseRequested: (callback: () => void) => () => void - confirmWindowClose: () => void -} - -type RuntimeApi = { - syncWindowGraph: (graph: RuntimeSyncWindowGraph) => Promise - getStatus: () => Promise -} - -type FsApi = { - readDir: (args: { dirPath: string }) => Promise - readFile: (args: { - filePath: string - }) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> - writeFile: (args: { filePath: string; content: string }) => Promise - createFile: (args: { filePath: string }) => Promise - createDir: (args: { dirPath: string }) => Promise - rename: (args: { oldPath: string; newPath: string }) => Promise - deletePath: (args: { targetPath: string }) => Promise - authorizeExternalPath: (args: { targetPath: string }) => Promise - stat: (args: { - filePath: string - }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> - listFiles: (args: { rootPath: string }) => Promise - search: (args: SearchOptions) => Promise -} - -type GitApi = { - status: (args: { worktreePath: string }) => Promise - conflictOperation: (args: { worktreePath: string }) => Promise - diff: (args: { - worktreePath: string - filePath: string - staged: boolean - }) => Promise - branchCompare: (args: { - worktreePath: string - baseRef: string - }) => Promise - branchDiff: (args: { - worktreePath: string - compare: { - baseRef: string - baseOid: string - headOid: string - mergeBase: string - } - filePath: string - oldPath?: string - }) => Promise - stage: (args: { worktreePath: string; filePath: string }) => Promise - bulkStage: (args: { worktreePath: string; filePaths: string[] }) => Promise - unstage: (args: { worktreePath: string; filePath: string }) => Promise - bulkUnstage: (args: { worktreePath: string; filePaths: string[] }) => Promise - discard: (args: { worktreePath: string; filePath: string }) => Promise - remoteFileUrl: (args: { - worktreePath: string - relativePath: string - line: number - }) => Promise -} - -type PreflightStatus = { - git: { installed: boolean } - gh: { installed: boolean; authenticated: boolean } -} - -type PreflightApi = { - check: (args?: { force?: boolean }) => Promise -} - -type StatsApi = { - getSummary: () => Promise -} - -type ClaudeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSummary: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getDaily: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getBreakdown: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - kind: ClaudeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise -} - -type Api = { +type Api = PreloadApi & { repos: ReposApi worktrees: WorktreesApi pty: PtyApi - gh: GhApi - settings: SettingsApi - cli: CliApi - preflight: PreflightApi - notifications: NotificationsApi - shell: ShellApi - hooks: HooksApi - cache: CacheApi - session: SessionApi - updater: UpdaterApi - stats: StatsApi - claudeUsage: ClaudeUsageApi - fs: FsApi - git: GitApi - ui: UIApi - runtime: RuntimeApi } declare global { diff --git a/src/preload/index.ts b/src/preload/index.ts index baab2bfbddf..483371078cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -305,6 +305,34 @@ const api = { ipcRenderer.invoke('shell:copyFile', args) }, + browser: { + registerGuest: (args: { browserTabId: string; webContentsId: number }): Promise => + ipcRenderer.invoke('browser:registerGuest', args), + + unregisterGuest: (args: { browserTabId: string }): Promise => + ipcRenderer.invoke('browser:unregisterGuest', args), + + openDevTools: (args: { browserTabId: string }): Promise => + ipcRenderer.invoke('browser:openDevTools', args), + + onGuestLoadFailed: ( + callback: (args: { + browserTabId: string + loadError: { code: number; description: string; validatedUrl: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserTabId: string + loadError: { code: number; description: string; validatedUrl: string } + } + ) => callback(data) + ipcRenderer.on('browser:guest-load-failed', listener) + return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) + } + }, + hooks: { check: (args: { repoId: string }): Promise<{ hasHooks: boolean; hooks: unknown }> => ipcRenderer.invoke('hooks:check', args) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 989d7abb862..6d15e842955 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -25,7 +25,7 @@ import { import { getVisibleWorktreeIds } from './components/sidebar/visible-worktrees' import { useGlobalFileDrop } from './hooks/useGlobalFileDrop' import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload' -import type { PersistedOpenFile } from '../../shared/types' +import type { BrowserTab, PersistedOpenFile, WorkspaceVisibleTabType } from '../../shared/types' import type { OpenFile } from './store/slices/editor' const isMac = navigator.userAgent.includes('Mac') @@ -36,11 +36,11 @@ const SIDEBAR_TRANSITION_MS = 200 function buildEditorSessionData( openFiles: OpenFile[], activeFileIdByWorktree: Record, - activeTabTypeByWorktree: Record + activeTabTypeByWorktree: Record ): { openFilesByWorktree: Record activeFileIdByWorktree: Record - activeTabTypeByWorktree: Record + activeTabTypeByWorktree: Record } { const editFiles = openFiles.filter((f) => f.mode === 'edit') const byWorktree: Record = {} @@ -61,6 +61,27 @@ function buildEditorSessionData( } } +function buildBrowserSessionData( + browserTabsByWorktree: Record, + activeBrowserTabIdByWorktree: Record +): { + browserTabsByWorktree: Record + activeBrowserTabIdByWorktree: Record +} { + return { + // Why: browser tabs persist only lightweight chrome state. Live guest + // webContents are recreated on restore, so loading is reset to false and + // transient errors are preserved only as last-known tab metadata. + browserTabsByWorktree: Object.fromEntries( + Object.entries(browserTabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => ({ ...tab, loading: false })) + ]) + ), + activeBrowserTabIdByWorktree + } +} + function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false @@ -101,6 +122,7 @@ function App(): React.JSX.Element { const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub) const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession) const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession) + const hydrateBrowserSession = useAppStore((s) => s.hydrateBrowserSession) const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals) const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI) const openModal = useAppStore((s) => s.openModal) @@ -118,6 +140,8 @@ function App(): React.JSX.Element { const activeFileIdByWorktree = useAppStore((s) => s.activeFileIdByWorktree) const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree) const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) + const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) + const activeBrowserTabIdByWorktree = useAppStore((s) => s.activeBrowserTabIdByWorktree) // Right sidebar + editor state const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar) @@ -158,6 +182,7 @@ function App(): React.JSX.Element { hydratePersistedUI(persistedUI) hydrateWorkspaceSession(session) hydrateEditorSession(session) + hydrateBrowserSession(session) await reconnectPersistedTerminals(abortController.signal) syncZoomCSSVar() } @@ -208,6 +233,7 @@ function App(): React.JSX.Element { hydratePersistedUI, hydrateWorkspaceSession, hydrateEditorSession, + hydrateBrowserSession, reconnectPersistedTerminals ]) @@ -246,7 +272,8 @@ function App(): React.JSX.Element { terminalLayoutsByTabId, activeWorktreeIdsOnShutdown, activeTabIdByWorktree, - ...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree) + ...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree), + ...buildBrowserSessionData(browserTabsByWorktree, activeBrowserTabIdByWorktree) }) }, 150) @@ -261,7 +288,9 @@ function App(): React.JSX.Element { openFiles, activeFileIdByWorktree, activeTabTypeByWorktree, - activeTabIdByWorktree + activeTabIdByWorktree, + browserTabsByWorktree, + activeBrowserTabIdByWorktree ]) // On shutdown, capture terminal scrollback buffers and flush to disk. @@ -294,7 +323,8 @@ function App(): React.JSX.Element { state.openFiles, state.activeFileIdByWorktree, state.activeTabTypeByWorktree - ) + ), + ...buildBrowserSessionData(state.browserTabsByWorktree, state.activeBrowserTabIdByWorktree) }) } window.addEventListener('beforeunload', captureAndFlush) diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 2d1af8fd948..3cec0284be5 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -21,6 +21,7 @@ import { } from './editor/editor-autosave' import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload' import EditorAutosaveController from './editor/EditorAutosaveController' +import BrowserPane, { destroyPersistentWebview } from './browser-pane/BrowserPane' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -41,12 +42,19 @@ export default function Terminal(): React.JSX.Element | null { const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const openFiles = useAppStore((s) => s.openFiles) const activeFileId = useAppStore((s) => s.activeFileId) + const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId) const activeTabType = useAppStore((s) => s.activeTabType) const setActiveTabType = useAppStore((s) => s.setActiveTabType) const setActiveFile = useAppStore((s) => s.setActiveFile) const closeFile = useAppStore((s) => s.closeFile) const closeAllFiles = useAppStore((s) => s.closeAllFiles) const pinFile = useAppStore((s) => s.pinFile) + const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) + const createBrowserTab = useAppStore((s) => s.createBrowserTab) + const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) + const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab) + const updateBrowserTabPageState = useAppStore((s) => s.updateBrowserTabPageState) + const setBrowserTabUrl = useAppStore((s) => s.setBrowserTabUrl) const markFileDirty = useAppStore((s) => s.markFileDirty) const setTabBarOrder = useAppStore((s) => s.setTabBarOrder) @@ -68,6 +76,12 @@ export default function Terminal(): React.JSX.Element | null { const worktreeFiles = activeWorktreeId ? openFiles.filter((f) => f.worktreeId === activeWorktreeId) : [] + const worktreeBrowserTabs = activeWorktreeId + ? (browserTabsByWorktree[activeWorktreeId] ?? []) + : [] + const activeWorktreeBrowserTabIdsKey = activeWorktreeId + ? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',') + : '' // Save confirmation dialog state const [saveDialogFileId, setSaveDialogFileId] = useState(null) @@ -164,7 +178,7 @@ export default function Terminal(): React.JSX.Element | null { // are open for this worktree. The user may have intentionally closed all // terminal tabs while keeping editors open — auto-spawning a terminal would // be disruptive. - if (tabs.length > 0 || worktreeFiles.length > 0) { + if (tabs.length > 0 || worktreeFiles.length > 0 || worktreeBrowserTabs.length > 0) { if (initialTabCreationGuardRef.current === activeWorktreeId) { initialTabCreationGuardRef.current = null } @@ -178,7 +192,14 @@ export default function Terminal(): React.JSX.Element | null { } initialTabCreationGuardRef.current = activeWorktreeId createTab(activeWorktreeId) - }, [workspaceSessionReady, activeWorktreeId, tabs.length, worktreeFiles.length, createTab]) + }, [ + workspaceSessionReady, + activeWorktreeId, + tabs.length, + worktreeFiles.length, + worktreeBrowserTabs.length, + createTab + ]) const handleNewTab = useCallback(() => { if (!activeWorktreeId) { @@ -193,13 +214,15 @@ export default function Terminal(): React.JSX.Element | null { const state = useAppStore.getState() const currentTerminals = state.tabsByWorktree[activeWorktreeId] ?? [] const currentEditors = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) + const currentBrowsers = state.browserTabsByWorktree[activeWorktreeId] ?? [] const stored = state.tabBarOrderByWorktree[activeWorktreeId] const termIds = currentTerminals.map((t) => t.id) const editorIds = currentEditors.map((f) => f.id) - const validIds = new Set([...termIds, ...editorIds]) + const browserIds = currentBrowsers.map((tab) => tab.id) + const validIds = new Set([...termIds, ...editorIds, ...browserIds]) const base = (stored ?? []).filter((id) => validIds.has(id)) const inBase = new Set(base) - for (const id of [...termIds, ...editorIds]) { + for (const id of [...termIds, ...editorIds, ...browserIds]) { if (!inBase.has(id)) { base.push(id) inBase.add(id) @@ -211,6 +234,13 @@ export default function Terminal(): React.JSX.Element | null { setTabBarOrder(activeWorktreeId, order) }, [activeWorktreeId, createTab, setActiveTabType, setTabBarOrder]) + const handleNewBrowserTab = useCallback(() => { + if (!activeWorktreeId) { + return + } + createBrowserTab(activeWorktreeId, 'about:blank', { title: 'New Browser Tab' }) + }, [activeWorktreeId, createBrowserTab]) + const handleCloseTab = useCallback( (tabId: string) => { const state = useAppStore.getState() @@ -235,7 +265,13 @@ export default function Terminal(): React.JSX.Element | null { setActiveFile(worktreeFile.id) setActiveTabType('editor') } else { - setActiveWorktree(null) + const browserTab = (state.browserTabsByWorktree[owningWorktreeId] ?? [])[0] + if (browserTab) { + setActiveBrowserTab(browserTab.id) + setActiveTabType('browser') + } else { + setActiveWorktree(null) + } } } return @@ -251,7 +287,65 @@ export default function Terminal(): React.JSX.Element | null { } closeTab(tabId) }, - [closeTab, setActiveTab, setActiveFile, setActiveTabType, setActiveWorktree] + [ + closeTab, + setActiveBrowserTab, + setActiveTab, + setActiveFile, + setActiveTabType, + setActiveWorktree + ] + ) + + const handleCloseBrowserTab = useCallback( + (tabId: string) => { + const state = useAppStore.getState() + const owningWorktreeEntry = Object.entries(state.browserTabsByWorktree).find( + ([, worktreeTabs]) => worktreeTabs.some((tab) => tab.id === tabId) + ) + const owningWorktreeId = owningWorktreeEntry?.[0] ?? null + if (!owningWorktreeId) { + return + } + const currentTabs = state.browserTabsByWorktree[owningWorktreeId] ?? [] + if (currentTabs.length <= 1) { + destroyPersistentWebview(tabId) + closeBrowserTab(tabId) + if (state.activeWorktreeId === owningWorktreeId) { + const worktreeFile = state.openFiles.find((file) => file.worktreeId === owningWorktreeId) + if (worktreeFile) { + setActiveFile(worktreeFile.id) + setActiveTabType('editor') + } else { + const terminalTab = (state.tabsByWorktree[owningWorktreeId] ?? [])[0] + if (terminalTab) { + setActiveTab(terminalTab.id) + setActiveTabType('terminal') + } else { + setActiveWorktree(null) + } + } + } + return + } + if (state.activeWorktreeId === owningWorktreeId && tabId === state.activeBrowserTabId) { + const idx = currentTabs.findIndex((tab) => tab.id === tabId) + const nextTab = currentTabs[idx + 1] ?? currentTabs[idx - 1] + if (nextTab) { + setActiveBrowserTab(nextTab.id) + } + } + destroyPersistentWebview(tabId) + closeBrowserTab(tabId) + }, + [ + closeBrowserTab, + setActiveBrowserTab, + setActiveFile, + setActiveTab, + setActiveTabType, + setActiveWorktree + ] ) const handlePtyExit = useCallback( @@ -269,15 +363,33 @@ export default function Terminal(): React.JSX.Element | null { if (!activeWorktreeId) { return } - const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? [] - setActiveTab(tabId) - for (const tab of currentTabs) { - if (tab.id !== tabId) { - closeTab(tab.id) + const state = useAppStore.getState() + const order = state.tabBarOrderByWorktree[activeWorktreeId] ?? [] + for (const id of order) { + if (id === tabId) { + continue + } + if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) { + closeTab(id) + } else if ( + state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id) + ) { + if ( + state.activeFileId === id && + state.openFiles.find((file) => file.id === id)?.isDirty + ) { + continue + } + closeFile(id) + } else if ( + (state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id) + ) { + destroyPersistentWebview(id) + closeBrowserTab(id) } } }, - [activeWorktreeId, closeTab, setActiveTab] + [activeWorktreeId, closeBrowserTab, closeFile, closeTab] ) const handleCloseTabsToRight = useCallback( @@ -285,17 +397,29 @@ export default function Terminal(): React.JSX.Element | null { if (!activeWorktreeId) { return } - const currentTabs = useAppStore.getState().tabsByWorktree[activeWorktreeId] ?? [] - const index = currentTabs.findIndex((t) => t.id === tabId) + const state = useAppStore.getState() + const currentOrder = state.tabBarOrderByWorktree[activeWorktreeId] ?? [] + const index = currentOrder.findIndex((id) => id === tabId) if (index === -1) { return } - const rightTabs = currentTabs.slice(index + 1) - for (const tab of rightTabs) { - closeTab(tab.id) + const rightIds = currentOrder.slice(index + 1) + for (const id of rightIds) { + if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) { + closeTab(id) + } else if ( + state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id) + ) { + closeFile(id) + } else if ( + (state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id) + ) { + destroyPersistentWebview(id) + closeBrowserTab(id) + } } }, - [activeWorktreeId, closeTab] + [activeWorktreeId, closeBrowserTab, closeFile, closeTab] ) const handleActivateTab = useCallback( @@ -320,6 +444,28 @@ export default function Terminal(): React.JSX.Element | null { [setActiveTab] ) + const handleActivateBrowserTab = useCallback( + (tabId: string) => { + setActiveBrowserTab(tabId) + setActiveTabType('browser') + }, + [setActiveBrowserTab, setActiveTabType] + ) + + const handleBrowserTabPageStateUpdate = useCallback( + (tabId: string, updates: Parameters[1]) => { + updateBrowserTabPageState(tabId, updates) + }, + [updateBrowserTabPageState] + ) + + const handleBrowserTabSetUrl = useCallback( + (tabId: string, url: string) => { + setBrowserTabUrl(tabId, url) + }, + [setBrowserTabUrl] + ) + // Keyboard shortcuts useEffect(() => { if (!activeWorktreeId) { @@ -329,14 +475,21 @@ export default function Terminal(): React.JSX.Element | null { const isMac = navigator.userAgent.includes('Mac') const onKeyDown = (e: KeyboardEvent): void => { const mod = isMac ? e.metaKey : e.ctrlKey - // Cmd/Ctrl+T - new tab + // Cmd/Ctrl+T - new terminal tab if (mod && e.key === 't' && !e.shiftKey && !e.repeat) { e.preventDefault() handleNewTab() return } - // Cmd/Ctrl+W - close active editor tab or terminal pane. + // Cmd/Ctrl+Shift+B - new browser tab + if (mod && e.shiftKey && e.key.toLowerCase() === 'b' && !e.repeat) { + e.preventDefault() + handleNewBrowserTab() + return + } + + // Cmd/Ctrl+W - close active editor tab, browser tab, or terminal pane. // Terminal pane/tab close is handled by the pane-level keyboard handler // in keyboard-handlers.ts so it can close individual split panes and // show a confirmation dialog. We still preventDefault here so Electron @@ -346,6 +499,8 @@ export default function Terminal(): React.JSX.Element | null { const state = useAppStore.getState() if (state.activeTabType === 'editor' && state.activeFileId) { handleCloseFile(state.activeFileId) + } else if (state.activeTabType === 'browser' && state.activeBrowserTabId) { + handleCloseBrowserTab(state.activeBrowserTabId) } return } @@ -354,26 +509,44 @@ export default function Terminal(): React.JSX.Element | null { if (mod && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) { const state = useAppStore.getState() const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? [] - const currentEditorFiles = activeWorktreeId - ? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) - : [] - - // Build unified tab list: terminal tabs then editor tabs - const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = [ - ...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })), - ...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id })) - ] + const currentEditorFiles = state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) + const currentBrowserTabs = state.browserTabsByWorktree[activeWorktreeId] ?? [] + const currentOrder = state.tabBarOrderByWorktree[activeWorktreeId] ?? [] + const allTabIds = currentOrder + .map((id) => { + if (currentTerminalTabs.some((tab) => tab.id === id)) { + return { type: 'terminal' as const, id } + } + if (currentEditorFiles.some((file) => file.id === id)) { + return { type: 'editor' as const, id } + } + if (currentBrowserTabs.some((tab) => tab.id === id)) { + return { type: 'browser' as const, id } + } + return null + }) + .filter( + (value): value is { type: 'terminal' | 'editor' | 'browser'; id: string } => + value !== null + ) if (allTabIds.length > 1) { e.preventDefault() const currentId = - state.activeTabType === 'editor' ? state.activeFileId : state.activeTabId + state.activeTabType === 'editor' + ? state.activeFileId + : state.activeTabType === 'browser' + ? state.activeBrowserTabId + : state.activeTabId const idx = allTabIds.findIndex((t) => t.id === currentId) const dir = e.key === ']' ? 1 : -1 const next = allTabIds[(idx + dir + allTabIds.length) % allTabIds.length] if (next.type === 'terminal') { setActiveTab(next.id) state.setActiveTabType('terminal') + } else if (next.type === 'browser') { + state.setActiveBrowserTab(next.id) + state.setActiveTabType('browser') } else { state.setActiveFile(next.id) state.setActiveTabType('editor') @@ -383,7 +556,15 @@ export default function Terminal(): React.JSX.Element | null { } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [activeWorktreeId, handleNewTab, handleCloseTab, handleCloseFile, setActiveTab]) + }, [ + activeWorktreeId, + handleNewBrowserTab, + handleNewTab, + handleCloseTab, + handleCloseBrowserTab, + handleCloseFile, + setActiveTab + ]) // Warn on window close if there are unsaved editor files useEffect(() => { @@ -429,6 +610,59 @@ export default function Terminal(): React.JSX.Element | null { }) }, []) + // Why: removeWorktree cleans up browser tab state in the store but cannot + // call destroyPersistentWebview (renderer-only DOM code). This subscriber + // detects when browser tabs disappear from a worktree (e.g. worktree deleted) + // and destroys orphaned webview elements to prevent memory leaks. + const prevBrowserTabIdsRef = useRef>(new Set()) + useEffect(() => { + return useAppStore.subscribe((state) => { + const currentIds = new Set( + Object.values(state.browserTabsByWorktree) + .flat() + .map((tab) => tab.id) + ) + for (const prevId of prevBrowserTabIdsRef.current) { + if (!currentIds.has(prevId)) { + destroyPersistentWebview(prevId) + } + } + prevBrowserTabIdsRef.current = currentIds + }) + }, []) + + // Why: defensive guard against state inconsistency. If activeTabType is + // 'browser' but no browser tab can be rendered (e.g. activeBrowserTabId is + // null or doesn't match any tab), fall back to terminal view instead of + // rendering a blank screen. This runs as an effect (not during render) + // because calling Zustand mutations during render interferes with React's + // render cycle and causes blank screens when creating new tabs. + useEffect(() => { + const activeWorktreeBrowserTabs = activeWorktreeId + ? (useAppStore.getState().browserTabsByWorktree[activeWorktreeId] ?? []) + : [] + if ( + activeTabType === 'browser' && + activeWorktreeId && + (!activeBrowserTabId || + !activeWorktreeBrowserTabs.some((tab) => tab.id === activeBrowserTabId)) + ) { + const fallbackBrowserTab = activeWorktreeBrowserTabs[0] + if (fallbackBrowserTab) { + setActiveBrowserTab(fallbackBrowserTab.id) + } else { + setActiveTabType('terminal') + } + } + }, [ + activeTabType, + activeWorktreeId, + activeBrowserTabId, + activeWorktreeBrowserTabIdsKey, + setActiveBrowserTab, + setActiveTabType + ]) + return (
{ setActiveFile(fileId) setActiveTabType('editor') }} onCloseFile={handleCloseFile} + onActivateBrowserTab={handleActivateBrowserTab} + onCloseBrowserTab={handleCloseBrowserTab} onCloseAllFiles={closeAllFiles} onPinFile={pinFile} tabBarOrder={tabBarOrder} @@ -472,7 +711,17 @@ export default function Terminal(): React.JSX.Element | null { {/* Terminal panes container - hidden when editor tab active */}
0 ? 'hidden' : ''}`} + className={`relative flex-1 min-h-0 overflow-hidden ${ + // Why: only hide the terminal container when another tab type has + // content to display. Hiding unconditionally for non-terminal types + // causes a blank screen when activeTabType is stale (e.g. 'editor' + // with no files after session restore). The terminal stays visible + // as a fallback until another surface is ready. + (activeTabType === 'editor' && worktreeFiles.length > 0) || + (activeTabType === 'browser' && worktreeBrowserTabs.length > 0) + ? 'hidden' + : '' + }`} > {allWorktrees .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id)) @@ -502,10 +751,42 @@ export default function Terminal(): React.JSX.Element | null { })}
- {/* Why: v1.0.85 only mounted the visible editor surface, which kept - hidden editor effects out of app shutdown. Autosave now lives in the - narrow EditorAutosaveController above, so the full EditorPanel can go - back to the safer "mount only while visible" lifecycle. */} + {/* Browser panes container — hidden when active tab is not a browser tab. + Only the active browser tab for the active worktree is mounted; others + are parked in a hidden off-screen container by BrowserPane to preserve + their webview guest process across tab switches. */} +
+ {allWorktrees.map((worktree) => { + const browserTabs = browserTabsByWorktree[worktree.id] ?? [] + const isVisibleWorktree = activeView !== 'settings' && worktree.id === activeWorktreeId + if (browserTabs.length === 0) { + return null + } + return ( +
+ {isVisibleWorktree && activeTabType === 'browser' + ? browserTabs + .filter((browserTab) => browserTab.id === activeBrowserTabId) + .map((browserTab) => ( + + )) + : null} +
+ ) + })} +
+ {activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && ( +> + +const webviewRegistry = new Map() +const registeredWebContentsIds = new Map() +const parkedAtByTabId = new Map() +let hiddenContainer: HTMLDivElement | null = null +const DRAG_LISTENER_KEY = '__orcaBrowserPaneDragListeners' +const MAX_PARKED_WEBVIEWS = 6 + +function getHiddenContainer(): HTMLDivElement { + if (!hiddenContainer) { + hiddenContainer = document.createElement('div') + hiddenContainer.style.position = 'fixed' + hiddenContainer.style.left = '-9999px' + hiddenContainer.style.top = '-9999px' + hiddenContainer.style.width = '100vw' + hiddenContainer.style.height = '100vh' + hiddenContainer.style.overflow = 'hidden' + hiddenContainer.style.pointerEvents = 'none' + document.body.appendChild(hiddenContainer) + } + return hiddenContainer +} + +function setWebviewsDragPassthrough(passthrough: boolean): void { + for (const webview of webviewRegistry.values()) { + webview.style.pointerEvents = passthrough ? 'none' : '' + } +} + +if (typeof window !== 'undefined') { + type DragListenerRegistry = { + dragstart: () => void + dragend: () => void + drop: () => void + } + const listenerHost = window as Window & { [DRAG_LISTENER_KEY]?: DragListenerRegistry } + const existingListeners = listenerHost[DRAG_LISTENER_KEY] + if (existingListeners) { + window.removeEventListener('dragstart', existingListeners.dragstart, true) + window.removeEventListener('dragend', existingListeners.dragend, true) + window.removeEventListener('drop', existingListeners.drop, true) + } + + const dragstart = (): void => setWebviewsDragPassthrough(true) + const dragend = (): void => setWebviewsDragPassthrough(false) + const drop = (): void => setWebviewsDragPassthrough(false) + + window.addEventListener('dragstart', dragstart, true) + window.addEventListener('dragend', dragend, true) + window.addEventListener('drop', drop, true) + // Why: BrowserPane installs process-wide drag listeners so parked webviews + // stop swallowing drop targets. We store/remove the previous handlers on + // window to keep Vite HMR from stacking duplicates across module reloads. + listenerHost[DRAG_LISTENER_KEY] = { dragstart, dragend, drop } +} + +export function destroyPersistentWebview(browserTabId: string): void { + const webview = webviewRegistry.get(browserTabId) + if (!webview) { + registeredWebContentsIds.delete(browserTabId) + parkedAtByTabId.delete(browserTabId) + clearLiveBrowserUrl(browserTabId) + return + } + void window.api.browser.unregisterGuest({ browserTabId }) + webview.remove() + webviewRegistry.delete(browserTabId) + registeredWebContentsIds.delete(browserTabId) + parkedAtByTabId.delete(browserTabId) + clearLiveBrowserUrl(browserTabId) +} + +function buildLoadError(event: { + errorCode?: number + errorDescription?: string + validatedURL?: string +}): BrowserLoadError { + return { + code: event.errorCode ?? -1, + description: event.errorDescription ?? 'Unknown load failure', + validatedUrl: event.validatedURL ?? 'about:blank' + } +} + +function toDisplayUrl(url: string): string { + return url === ORCA_BROWSER_BLANK_URL ? 'about:blank' : url +} + +function isChromiumErrorPage(url: string): boolean { + return url.startsWith('chrome-error://') +} + +function getLoadErrorMetadata(loadError: BrowserLoadError | null): { + displayUrl: string + host: string | null + isLocalhostLike: boolean +} { + const rawUrl = loadError?.validatedUrl ?? 'about:blank' + const displayUrl = toDisplayUrl(rawUrl) + try { + const parsed = new URL(rawUrl) + const host = parsed.host || null + const hostname = parsed.hostname + const isLocalhostLike = + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '0.0.0.0' || + hostname === '::1' + return { displayUrl, host, isLocalhostLike } + } catch { + return { displayUrl, host: null, isLocalhostLike: false } + } +} + +function getFriendlyLoadErrorDescription(loadError: BrowserLoadError | null): string { + if (!loadError) { + return 'The page did not respond.' + } + if (loadError.code === 0) { + return loadError.description + } + return "We couldn't connect to this page." +} + +function getOpenableExternalUrl( + webview: Electron.WebviewTag | null, + fallbackUrl: string +): string | null { + let currentUrl = fallbackUrl + if (webview) { + try { + currentUrl = webview.getURL() || fallbackUrl + } catch { + // Why: restored browser tabs render before the guest emits dom-ready. + // Electron throws if toolbar code queries navigation state too early, and + // that renderer exception blanks the whole IDE on launch. Fall back to the + // persisted tab URL until the guest is fully attached. + currentUrl = fallbackUrl + } + } + return normalizeExternalBrowserUrl(currentUrl) +} + +function retryBrowserTabLoad( + webview: Electron.WebviewTag | null, + browserTab: BrowserTabState, + onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void +): void { + if (!webview) { + return + } + + const retryUrl = normalizeBrowserNavigationUrl( + browserTab.loadError?.validatedUrl ?? browserTab.url + ) + if (!retryUrl) { + return + } + + // Why: once Chromium lands on chrome-error://chromewebdata/, reload() can + // simply refresh the internal error page instead of retrying the original + // destination. Force navigation back to the attempted URL so Retry and the + // toolbar reload button actually re-attempt the failed page. Keep the last + // failure visible until a real success arrives so retry does not briefly + // drop the user back to a blank black guest surface. + onUpdatePageState(browserTab.id, { + loading: true, + title: retryUrl + }) + webview.src = retryUrl +} + +function evictParkedWebviews(excludedTabId: string | null = null): void { + if (webviewRegistry.size <= MAX_PARKED_WEBVIEWS) { + return + } + + const hidden = getHiddenContainer() + const parkedBrowserTabIds = [...webviewRegistry.entries()] + .filter( + ([browserTabId, webview]) => + browserTabId !== excludedTabId && webview.parentElement === hidden + ) + .sort((a, b) => (parkedAtByTabId.get(a[0]) ?? 0) - (parkedAtByTabId.get(b[0]) ?? 0)) + .map(([browserTabId]) => browserTabId) + + while (webviewRegistry.size > MAX_PARKED_WEBVIEWS && parkedBrowserTabIds.length > 0) { + const browserTabId = parkedBrowserTabIds.shift() + if (browserTabId) { + // Why: browser tabs are persistent for fast switching, but hidden guests + // cannot grow without bound or long Orca sessions accumulate Chromium + // processes and GPU surfaces. Evict only parked webviews, never the + // currently visible guest. Remember the eviction so the next mount can + // explain why an older tab had to reload instead of silently losing state. + markEvictedBrowserTab(browserTabId) + destroyPersistentWebview(browserTabId) + } + } +} + +export default function BrowserPane({ + browserTab, + onUpdatePageState, + onSetUrl +}: { + browserTab: BrowserTabState + onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + onSetUrl: (tabId: string, url: string) => void +}): React.JSX.Element { + const containerRef = useRef(null) + const webviewRef = useRef(null) + const faviconUrlRef = useRef(browserTab.faviconUrl) + const initialBrowserUrlRef = useRef(browserTab.url) + const browserTabUrlRef = useRef(browserTab.url) + const activeLoadFailureRef = useRef(browserTab.loadError) + const trackNextLoadingEventRef = useRef(false) + const onUpdatePageStateRef = useRef(onUpdatePageState) + const onSetUrlRef = useRef(onSetUrl) + const [addressBarValue, setAddressBarValue] = useState(browserTab.url) + const addressBarValueRef = useRef(browserTab.url) + const [resourceNotice, setResourceNotice] = useState(null) + + useEffect(() => { + setAddressBarValue(toDisplayUrl(browserTab.url)) + }, [browserTab.url]) + + useEffect(() => { + browserTabUrlRef.current = browserTab.url + }, [browserTab.url]) + + useEffect(() => { + activeLoadFailureRef.current = browserTab.loadError + }, [browserTab.loadError]) + + useEffect(() => { + addressBarValueRef.current = addressBarValue + }, [addressBarValue]) + + useEffect(() => { + setResourceNotice( + consumeEvictedBrowserTab(browserTab.id) + ? 'This tab reloaded to free browser resources.' + : null + ) + }, [browserTab.id]) + + useEffect(() => { + onUpdatePageStateRef.current = onUpdatePageState + onSetUrlRef.current = onSetUrl + }, [onSetUrl, onUpdatePageState]) + + const syncNavigationState = useCallback( + (webview: Electron.WebviewTag): void => { + try { + onUpdatePageStateRef.current(browserTab.id, { + title: webview.getTitle() || webview.getURL() || 'Browser', + // Why: webview reclaim/attach can transiently report isLoading() even + // when no user-visible navigation happened. If we sync that into the + // tab model on every activation, switching tabs flashes the blue + // loading dot and makes parked tabs look like they are reloading. + // Only explicit navigation/load events should drive Orca's loading UI. + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward() + }) + } catch { + // Why: Electron only exposes these getters after the guest fully + // attaches. Ignoring the transient failure avoids crashing Orca while + // the parked webview is being reclaimed into the visible tab body. + } + }, + [browserTab.id] + ) + + useEffect(() => { + const container = containerRef.current + if (!container) { + return + } + + let webview = webviewRegistry.get(browserTab.id) + let needsInitialNavigation = false + if (webview) { + container.appendChild(webview) + parkedAtByTabId.delete(browserTab.id) + syncNavigationState(webview) + } else { + webview = document.createElement('webview') as Electron.WebviewTag + webview.setAttribute('partition', ORCA_BROWSER_PARTITION) + webview.setAttribute('allowpopups', '') + webview.style.display = 'flex' + webview.style.flex = '1' + webview.style.width = '100%' + webview.style.height = '100%' + webview.style.border = 'none' + webview.style.background = 'transparent' + webviewRegistry.set(browserTab.id, webview) + container.appendChild(webview) + needsInitialNavigation = true + } + + webviewRef.current = webview + + const handleDomReady = (): void => { + const webContentsId = webview.getWebContentsId() + if (registeredWebContentsIds.get(browserTab.id) !== webContentsId) { + registeredWebContentsIds.set(browserTab.id, webContentsId) + void window.api.browser.registerGuest({ + browserTabId: browserTab.id, + webContentsId + }) + } + syncNavigationState(webview) + } + + const handleDidStartLoading = (): void => { + if (!trackNextLoadingEventRef.current) { + return + } + faviconUrlRef.current = null + onUpdatePageStateRef.current(browserTab.id, { + loading: true, + faviconUrl: null + }) + } + + const handleDidStopLoading = (): void => { + const currentUrl = webview.getURL() || webview.src || 'about:blank' + const activeLoadFailure = activeLoadFailureRef.current + if (isChromiumErrorPage(currentUrl)) { + trackNextLoadingEventRef.current = false + const synthesizedFailure = { + code: -1, + description: 'This site could not be reached.', + validatedUrl: browserTabUrlRef.current || addressBarValueRef.current || 'about:blank' + } + activeLoadFailureRef.current = synthesizedFailure + onUpdatePageStateRef.current(browserTab.id, { + loading: false, + loadError: synthesizedFailure + }) + return + } + if (activeLoadFailure) { + const normalizedAttemptedUrl = + normalizeBrowserNavigationUrl(activeLoadFailure.validatedUrl) ?? + activeLoadFailure.validatedUrl + const normalizedCurrentUrl = normalizeBrowserNavigationUrl(currentUrl) ?? currentUrl + if (normalizedAttemptedUrl === normalizedCurrentUrl) { + trackNextLoadingEventRef.current = false + // Why: some webview failures still emit did-stop-loading on the + // original destination URL. If we clear loadError here, the failed + // navigation falls back to a blank Chromium surface even though Orca + // already knows this exact load failed. + onUpdatePageStateRef.current(browserTab.id, { + loading: false, + title: webview.getTitle() || currentUrl, + faviconUrl: faviconUrlRef.current, + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + loadError: activeLoadFailure + }) + return + } + } + trackNextLoadingEventRef.current = false + activeLoadFailureRef.current = null + rememberLiveBrowserUrl(browserTab.id, currentUrl) + setAddressBarValue(toDisplayUrl(currentUrl)) + onSetUrlRef.current(browserTab.id, currentUrl) + onUpdatePageStateRef.current(browserTab.id, { + loading: false, + title: webview.getTitle() || currentUrl, + faviconUrl: faviconUrlRef.current, + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward(), + loadError: null + }) + } + + const handleDidNavigate = (event: { url?: string; isMainFrame?: boolean }): void => { + if (event.isMainFrame === false) { + return + } + const currentUrl = event.url ?? webview.getURL() ?? webview.src ?? 'about:blank' + if (isChromiumErrorPage(currentUrl)) { + return + } + rememberLiveBrowserUrl(browserTab.id, currentUrl) + setAddressBarValue(toDisplayUrl(currentUrl)) + onSetUrlRef.current(browserTab.id, currentUrl) + onUpdatePageStateRef.current(browserTab.id, { + title: webview.getTitle() || currentUrl, + canGoBack: webview.canGoBack(), + canGoForward: webview.canGoForward() + }) + } + + const handleTitleUpdate = (event: { title?: string }): void => { + onUpdatePageStateRef.current(browserTab.id, { + title: event.title ?? webview.getURL() ?? 'Browser' + }) + } + + const handleFaviconUpdate = (event: { favicons?: string[] }): void => { + const faviconUrl = event.favicons?.[0] ?? null + faviconUrlRef.current = + faviconUrl && + (faviconUrl.startsWith('https://') || + faviconUrl.startsWith('http://') || + faviconUrl.startsWith('data:image/')) + ? faviconUrl + : null + onUpdatePageStateRef.current(browserTab.id, { faviconUrl: faviconUrlRef.current }) + } + + const handleFailLoad = (event: { + errorCode?: number + errorDescription?: string + validatedURL?: string + isMainFrame?: boolean + }): void => { + if (event.isMainFrame === false) { + return + } + if (event.errorCode === -3) { + // Why: Chromium reports redirect/cancel races as ERR_ABORTED (-3) even + // when the replacement navigation succeeds. Ignore that noise so Orca + // does not show a false load failure for a working page. + return + } + trackNextLoadingEventRef.current = false + const loadError = buildLoadError(event) + activeLoadFailureRef.current = loadError + onUpdatePageStateRef.current(browserTab.id, { + loading: false, + loadError + }) + } + + webview.addEventListener('dom-ready', handleDomReady) + webview.addEventListener('did-start-loading', handleDidStartLoading) + webview.addEventListener('did-stop-loading', handleDidStopLoading) + webview.addEventListener('did-navigate', handleDidNavigate) + webview.addEventListener('did-navigate-in-page', handleDidNavigate) + webview.addEventListener('page-title-updated', handleTitleUpdate) + webview.addEventListener('page-favicon-updated', handleFaviconUpdate) + webview.addEventListener('did-fail-load', handleFailLoad) + + if (needsInitialNavigation) { + // Why: connection-refused localhost tabs can fail before Electron wires up + // event delivery if src is assigned too early. Attach listeners first so + // Orca never misses the initial did-fail-load signal for a new tab. + // Only non-blank initial tabs should light up Orca's loading indicator; + // reclaiming/activating a parked about:blank tab is not a meaningful + // navigation and should not flash the tab-loading dot. + trackNextLoadingEventRef.current = + (normalizeBrowserNavigationUrl(initialBrowserUrlRef.current) ?? ORCA_BROWSER_BLANK_URL) !== + ORCA_BROWSER_BLANK_URL + webview.src = + normalizeBrowserNavigationUrl(initialBrowserUrlRef.current) ?? ORCA_BROWSER_BLANK_URL + } + + return () => { + webview.removeEventListener('dom-ready', handleDomReady) + webview.removeEventListener('did-start-loading', handleDidStartLoading) + webview.removeEventListener('did-stop-loading', handleDidStopLoading) + webview.removeEventListener('did-navigate', handleDidNavigate) + webview.removeEventListener('did-navigate-in-page', handleDidNavigate) + webview.removeEventListener('page-title-updated', handleTitleUpdate) + webview.removeEventListener('page-favicon-updated', handleFaviconUpdate) + webview.removeEventListener('did-fail-load', handleFailLoad) + + if (webviewRef.current === webview) { + webviewRef.current = null + } + + if (webviewRegistry.get(browserTab.id) === webview) { + getHiddenContainer().appendChild(webview) + parkedAtByTabId.set(browserTab.id, Date.now()) + evictParkedWebviews(browserTab.id) + } + } + }, [browserTab.id, syncNavigationState]) + + useEffect(() => { + const webview = webviewRef.current + if (!webview) { + return + } + const normalizedUrl = normalizeBrowserNavigationUrl(browserTab.url) + if (!normalizedUrl) { + return + } + if (webview.src !== normalizedUrl && webview.getAttribute('src') !== normalizedUrl) { + // Why: browserTab.url changes are Orca-driven navigations (address bar, + // terminal link open, retry target update). Gate the next did-start-loading + // event so only real navigations, not tab activation churn, show loading UI. + trackNextLoadingEventRef.current = normalizedUrl !== ORCA_BROWSER_BLANK_URL + webview.src = normalizedUrl + } + }, [browserTab.url]) + + useEffect(() => { + if (!browserTab.loading) { + return + } + + const detectChromiumErrorPage = (): void => { + const webview = webviewRef.current + if (!webview) { + return + } + try { + const currentUrl = webview.getURL() || webview.src || '' + if (!isChromiumErrorPage(currentUrl)) { + return + } + + const attemptedUrl = browserTabUrlRef.current || addressBarValueRef.current || 'about:blank' + onUpdatePageStateRef.current(browserTab.id, { + loading: false, + loadError: { + code: -1, + description: 'This site could not be reached.', + validatedUrl: attemptedUrl + } + }) + } catch { + // Why: the guest can still be mid-attach while the loading spinner is + // visible. Polling is only a fallback for missed failure events, so + // transient getURL() errors should be ignored until the next tick. + } + } + + // Why: some Electron builds paint Chromium's internal chrome-error page + // without delivering a timely did-fail-load event to the renderer webview. + // Polling only while the tab is "loading" gives Orca a last-resort path to + // swap the black guest surface for the explicit unreachable-page overlay. + detectChromiumErrorPage() + const intervalId = window.setInterval(detectChromiumErrorPage, 250) + return () => window.clearInterval(intervalId) + }, [browserTab.id, browserTab.loading]) + + const submitAddressBar = (): void => { + const nextUrl = normalizeBrowserNavigationUrl(addressBarValue) + if (!nextUrl) { + onUpdatePageStateRef.current(browserTab.id, { + loadError: { + code: 0, + description: 'Enter a valid http(s) or localhost URL.', + validatedUrl: addressBarValue.trim() || 'about:blank' + } + }) + return + } + + setAddressBarValue(toDisplayUrl(nextUrl)) + onSetUrlRef.current(browserTab.id, nextUrl) + onUpdatePageStateRef.current(browserTab.id, { loading: true, loadError: null, title: nextUrl }) + setResourceNotice(null) + + const webview = webviewRef.current + if (!webview) { + return + } + trackNextLoadingEventRef.current = nextUrl !== ORCA_BROWSER_BLANK_URL + webview.src = nextUrl + } + + // Why: the store initially holds 'about:blank', but once the webview loads + // with the safe data: URL, handleDidStopLoading writes the resolved URL back. + // Match both so the "New Browser Tab" overlay stays visible for blank tabs. + const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL + const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) + const loadErrorMeta = getLoadErrorMetadata(browserTab.loadError) + const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab + + useEffect(() => { + const webview = webviewRef.current + if (!webview) { + return + } + // Why: Electron webviews render in their own compositor layer, so a React + // overlay can sit "under" a failed guest and still look like a black page. + // Fully removing the guest from layout is more reliable than visibility + // toggles here; some Electron builds keep painting a hidden guest layer. + webview.style.display = showFailureOverlay ? 'none' : 'flex' + }, [showFailureOverlay]) + + return ( +
+
+ + + + +
{ + event.preventDefault() + submitAddressBar() + }} + > + + setAddressBarValue(event.target.value)} + className="h-auto border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0" + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + /> + + + + + +
+ {resourceNotice ? ( +
+ {resourceNotice} +
+ ) : null} +
+ {showFailureOverlay ? ( +
+
+
+ +
+

+ {loadErrorMeta.host ? `Can't reach ${loadErrorMeta.host}` : "Can't load this page"} +

+

+ {getFriendlyLoadErrorDescription(browserTab.loadError)} +

+
+ +
+
+
+ ) : null} + {isBlankTab ? ( +
+
+
+ +
+
+

New Browser Tab

+

+ Type a URL above to start browsing. +

+
+
+
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-runtime.ts b/src/renderer/src/components/browser-pane/browser-runtime.ts new file mode 100644 index 00000000000..41acc02cca1 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-runtime.ts @@ -0,0 +1,26 @@ +const liveBrowserUrlByTabId = new Map() +const evictedBrowserTabIds = new Set() + +export function rememberLiveBrowserUrl(browserTabId: string, url: string): void { + liveBrowserUrlByTabId.set(browserTabId, url) +} + +export function getLiveBrowserUrl(browserTabId: string): string | null { + return liveBrowserUrlByTabId.get(browserTabId) ?? null +} + +export function clearLiveBrowserUrl(browserTabId: string): void { + liveBrowserUrlByTabId.delete(browserTabId) +} + +export function markEvictedBrowserTab(browserTabId: string): void { + evictedBrowserTabIds.add(browserTabId) +} + +export function consumeEvictedBrowserTab(browserTabId: string): boolean { + const wasEvicted = evictedBrowserTabIds.has(browserTabId) + if (wasEvicted) { + evictedBrowserTabIds.delete(browserTabId) + } + return wasEvicted +} diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index a0a1906c9bd..67b7f69ddda 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -158,6 +158,39 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea /> + + +
+ +

+ Open terminal http(s) links in isolated Orca browser tabs instead of the system + browser. +

+
+ +
) : null, matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? ( diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts new file mode 100644 index 00000000000..72ea75fc080 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' + +vi.mock('@/lib/agent-status', () => ({ + detectAgentStatusFromTitle: vi.fn((title: string) => { + if (title.includes('permission')) { + return 'permission' + } + if (title.includes('working')) { + return 'working' + } + return null + }) +})) + +import { getWorktreeStatus } from './WorktreeCard' + +function makeTerminalTab(title: string): TerminalTab { + return { + id: 'tab-1', + worktreeId: 'repo1::/tmp/wt', + ptyId: 'pty-1', + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +describe('getWorktreeStatus', () => { + it('treats browser-only worktrees as active', () => { + expect(getWorktreeStatus([], [{ id: 'browser-1' }])).toBe('active') + }) + + it('keeps terminal agent states higher priority than browser presence', () => { + expect( + getWorktreeStatus([makeTerminalTab('permission needed')], [{ id: 'browser-1' }]) + ).toBe('permission') + expect( + getWorktreeStatus([makeTerminalTab('working hard')], [{ id: 'browser-1' }]) + ).toBe('working') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 63aa58cbcb1..3223d142439 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -58,6 +58,25 @@ const CONFLICT_OPERATION_LABELS: Record // ── Stable empty array for tabs fallback ───────────────────────── const EMPTY_TABS: TerminalTab[] = [] +const EMPTY_BROWSER_TABS: { id: string }[] = [] + +export function getWorktreeStatus(tabs: TerminalTab[], browserTabs: { id: string }[]): Status { + const liveTabs = tabs.filter((tab) => tab.ptyId) + if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) { + return 'permission' + } + if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) { + return 'working' + } + if (liveTabs.length > 0 || browserTabs.length > 0) { + // Why: browser-only worktrees are still active from the user's point of + // view even when they have no PTY-backed terminal. The sidebar filter + // already treats them as active, so the card badge must stay consistent + // instead of showing a misleading inactive dot. + return 'active' + } + return 'inactive' +} type WorktreeCardProps = { worktree: Worktree @@ -138,6 +157,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ // ── GRANULAR selectors: only subscribe to THIS worktree's data ── const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS) + const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS) const branch = branchDisplayName(worktree.branch) const isFolder = repo ? isFolderRepo(repo) : false @@ -155,23 +175,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ : undefined : null - const hasTerminals = tabs.length > 0 const isDeleting = deleteState?.isDeleting ?? false // Derive status - const status: Status = useMemo(() => { - if (!hasTerminals) { - return 'inactive' - } - const liveTabs = tabs.filter((tab) => tab.ptyId) - if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) { - return 'permission' - } - if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) { - return 'working' - } - return liveTabs.length > 0 ? 'active' : 'inactive' - }, [hasTerminals, tabs]) + const status: Status = useMemo(() => getWorktreeStatus(tabs, browserTabs), [tabs, browserTabs]) const showPR = cardProps.includes('pr') const showCI = cardProps.includes('ci') diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 3db6ac6f87a..497660356d3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -38,6 +38,9 @@ const WorktreeList = React.memo(function WorktreeList() { // Read tabsByWorktree when needed for filtering or sorting const needsTabs = showActiveOnly || sortBy === 'recent' const tabsByWorktree = useAppStore((s) => (needsTabs ? s.tabsByWorktree : null)) + const browserTabsByWorktree = useAppStore((s) => + showActiveOnly ? s.browserTabsByWorktree : null + ) const cardProps = useAppStore((s) => s.worktreeCardProperties) @@ -183,6 +186,8 @@ const WorktreeList = React.memo(function WorktreeList() { searchQuery, showActiveOnly, tabsByWorktree, + browserTabsByWorktree, + activeWorktreeId, repoMap, prCache, issueCache @@ -200,8 +205,10 @@ const WorktreeList = React.memo(function WorktreeList() { filterRepoIds, searchQuery, showActiveOnly, + activeWorktreeId, repoMap, tabsByWorktree, + browserTabsByWorktree, sortedIds, prCache, issueCache diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts new file mode 100644 index 00000000000..63778e52539 --- /dev/null +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { computeVisibleWorktreeIds } from './visible-worktrees' +import type { Repo, Worktree } from '../../../../shared/types' + +function makeWorktree(id: string, repoId = 'repo1'): Worktree { + return { + id, + repoId, + path: `/tmp/${id}`, + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + isArchived: false, + isUnread: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +const repoMap = new Map([ + [ + 'repo1', + { + id: 'repo1', + path: '/repo1', + displayName: 'Repo 1', + badgeColor: '#000', + addedAt: 0 + } + ] +]) + +describe('computeVisibleWorktreeIds', () => { + it('treats browser-tab worktrees as active for the active-only filter', () => { + const wt = makeWorktree('wt-browser') + + const result = computeVisibleWorktreeIds({ repo1: [wt] }, [wt.id], { + filterRepoIds: [], + searchQuery: '', + showActiveOnly: true, + tabsByWorktree: {}, + browserTabsByWorktree: { [wt.id]: [{ id: 'browser-1' }] }, + activeWorktreeId: null, + repoMap, + prCache: null, + issueCache: null + }) + + expect(result).toEqual([wt.id]) + }) + + it('keeps the currently active worktree visible even without PTYs', () => { + const wt = makeWorktree('wt-active') + + const result = computeVisibleWorktreeIds({ repo1: [wt] }, [wt.id], { + filterRepoIds: [], + searchQuery: '', + showActiveOnly: true, + tabsByWorktree: {}, + browserTabsByWorktree: {}, + activeWorktreeId: wt.id, + repoMap, + prCache: null, + issueCache: null + }) + + expect(result).toEqual([wt.id]) + }) +}) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 764efdefd29..40236036601 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -22,6 +22,8 @@ export function computeVisibleWorktreeIds( searchQuery: string showActiveOnly: boolean tabsByWorktree: Record | null + browserTabsByWorktree?: Record | null + activeWorktreeId?: string | null repoMap: Map prCache: AppState['prCache'] | null issueCache: AppState['issueCache'] | null @@ -49,7 +51,13 @@ export function computeVisibleWorktreeIds( if (opts.showActiveOnly) { all = all.filter((w) => { const tabs = opts.tabsByWorktree?.[w.id] ?? [] - return tabs.some((t) => t.ptyId) + const hasLiveTerminal = tabs.some((t) => t.ptyId) + const hasBrowserTabs = (opts.browserTabsByWorktree?.[w.id] ?? []).length > 0 + // Why: "Active only" should reflect the surfaces Orca can actually + // restore into, not just PTY-backed terminals. A browser-tab worktree is + // still active from the user's point of view even if it has no live PTY, + // and the currently selected worktree should never vanish from the list. + return hasLiveTerminal || hasBrowserTabs || opts.activeWorktreeId === w.id }) } @@ -153,6 +161,8 @@ export function getVisibleWorktreeIds(): string[] { searchQuery: state.searchQuery, showActiveOnly: state.showActiveOnly, tabsByWorktree: state.tabsByWorktree, + browserTabsByWorktree: state.browserTabsByWorktree, + activeWorktreeId: state.activeWorktreeId, repoMap, prCache: state.prCache, issueCache: state.issueCache diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx new file mode 100644 index 00000000000..283ba9900c0 --- /dev/null +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Globe, X, ExternalLink } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' +import type { BrowserTab as BrowserTabState } from '../../../../shared/types' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab' +import { getLiveBrowserUrl } from '../browser-pane/browser-runtime' + +function formatBrowserTabUrlLabel(url: string): string { + if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') { + return 'New Browser Tab' + } + try { + const parsed = new URL(url) + return `${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}${parsed.search}${parsed.hash}` + } catch { + return url + } +} + +function getBrowserTabLabel(tab: BrowserTabState): string { + if ( + !tab.title || + tab.title === tab.url || + tab.title === ORCA_BROWSER_BLANK_URL || + tab.title === 'about:blank' + ) { + return formatBrowserTabUrlLabel(tab.url) + } + + return tab.title || tab.url +} + +function isBlankBrowserTab(tab: BrowserTabState): boolean { + return tab.url === ORCA_BROWSER_BLANK_URL || tab.url === 'about:blank' +} + +export default function BrowserTab({ + tab, + isActive, + hasTabsToRight, + onActivate, + onClose, + onCloseToRight +}: { + tab: BrowserTabState + isActive: boolean + hasTabsToRight: boolean + onActivate: () => void + onClose: () => void + onCloseToRight: () => void +}): React.JSX.Element { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: tab.id + }) + const [menuOpen, setMenuOpen] = useState(false) + const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) + + // Why: about:blank and other non-http URLs should not be sent to the + // system browser. Disable the context menu item instead of silently + // calling shell.openUrl with an unsupported URL. + const openInBrowserUrl = getLiveBrowserUrl(tab.id) ?? tab.url + let isHttpUrl = false + try { + const parsed = new URL(openInBrowserUrl) + isHttpUrl = parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + // invalid URL — leave disabled + } + + useEffect(() => { + const closeMenu = (): void => setMenuOpen(false) + window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) + return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) + }, []) + + return ( + <> +
{ + event.preventDefault() + window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + setMenuPoint({ x: event.clientX, y: event.clientY }) + setMenuOpen(true) + }} + > +
{ + if (e.button !== 0) { + return + } + onActivate() + listeners?.onPointerDown?.(e) + }} + onMouseDown={(e) => { + if (e.button === 1) { + e.preventDefault() + } + }} + onAuxClick={(e) => { + if (e.button === 1) { + e.preventDefault() + e.stopPropagation() + onClose() + } + }} + > + + {getBrowserTabLabel(tab)} + {tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && ( + + )} + +
+
+ + + + + + + + + + + + New Terminal + {NEW_TERMINAL_SHORTCUT} + + + + New Browser Tab + {NEW_BROWSER_SHORTCUT} + + +
) } diff --git a/src/renderer/src/components/tab-bar/reconcile-order.ts b/src/renderer/src/components/tab-bar/reconcile-order.ts index 976931d088c..df055c831d8 100644 --- a/src/renderer/src/components/tab-bar/reconcile-order.ts +++ b/src/renderer/src/components/tab-bar/reconcile-order.ts @@ -6,12 +6,13 @@ export function reconcileTabOrder( storedOrder: string[] | undefined, terminalIds: string[], - editorIds: string[] + editorIds: string[], + browserIds: string[] = [] ): string[] { - const validIds = new Set([...terminalIds, ...editorIds]) + const validIds = new Set([...terminalIds, ...editorIds, ...browserIds]) const result: string[] = (storedOrder ?? []).filter((id) => validIds.has(id)) const inResult = new Set(result) - for (const id of [...terminalIds, ...editorIds]) { + for (const id of [...terminalIds, ...editorIds, ...browserIds]) { if (!inResult.has(id)) { result.push(id) inResult.add(id) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index a9f698ff2db..48e0150af27 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -173,6 +173,15 @@ export function handleOscLink( } if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + const store = useAppStore.getState() + // Why: when the user opts into Orca's browser tabs, terminal links should + // stay worktree-scoped instead of escaping to the system browser. We still + // fall back externally when the setting is off or no worktree owns the pane. + if (store.settings?.openLinksInApp && deps.worktreeId) { + store.setActiveWorktree(deps.worktreeId) + store.createBrowserTab(deps.worktreeId, parsed.toString()) + return + } void window.api.shell.openUrl(parsed.toString()) return } diff --git a/src/renderer/src/components/terminal/TerminalShell.tsx b/src/renderer/src/components/terminal/TerminalShell.tsx index 0fef07f02d9..9b29e6ac207 100644 --- a/src/renderer/src/components/terminal/TerminalShell.tsx +++ b/src/renderer/src/components/terminal/TerminalShell.tsx @@ -29,7 +29,8 @@ type TerminalShellProps = { onCloseOthers: (tabId: string) => void onCloseTabsToRight: (tabId: string) => void onReorderTabs: (worktreeId: string, tabIds: string[]) => void - onNewTab: () => void + onNewTerminalTab: () => void + onNewBrowserTab: () => void onSetCustomTitle: (tabId: string, title: string | null) => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePaneExpand: (tabId: string) => void @@ -64,7 +65,8 @@ export function TerminalShell({ onCloseOthers, onCloseTabsToRight, onReorderTabs, - onNewTab, + onNewTerminalTab, + onNewBrowserTab, onSetCustomTitle, onSetTabColor, onTogglePaneExpand, @@ -101,7 +103,8 @@ export function TerminalShell({ onCloseOthers={onCloseOthers} onCloseToRight={onCloseTabsToRight} onReorder={onReorderTabs} - onNewTab={onNewTab} + onNewTerminalTab={onNewTerminalTab} + onNewBrowserTab={onNewBrowserTab} onSetCustomTitle={onSetCustomTitle} onSetTabColor={onSetTabColor} expandedPaneByTabId={expandedPaneByTabId} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b32d0e2f901..d8bf1d65c04 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -12,7 +12,7 @@ const ZOOM_STEP = 0.5 export function resolveZoomTarget(args: { activeView: 'terminal' | 'settings' - activeTabType: 'terminal' | 'editor' + activeTabType: 'terminal' | 'editor' | 'browser' activeElement: unknown }): 'terminal' | 'editor' | 'ui' { const { activeView, activeTabType, activeElement } = args @@ -119,6 +119,17 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.browser.onGuestLoadFailed(({ browserTabId, loadError }) => { + useAppStore.getState().updateBrowserTabPageState(browserTabId, { + loading: false, + loadError, + canGoBack: false, + canGoForward: false + }) + }) + ) + // Zoom handling for menu accelerators and keyboard fallback paths. unsubs.push( window.api.ui.onTerminalZoom((direction) => { diff --git a/src/renderer/src/mermaid.d.ts b/src/renderer/src/mermaid.d.ts new file mode 100644 index 00000000000..5c36ddb5458 --- /dev/null +++ b/src/renderer/src/mermaid.d.ts @@ -0,0 +1,21 @@ +declare module 'mermaid' { + type MermaidTheme = 'default' | 'dark' + + type MermaidInitializeOptions = { + startOnLoad?: boolean + theme?: MermaidTheme + } + + type MermaidRenderResult = { + svg: string + bindFunctions?: (element: Element) => void + } + + type MermaidApi = { + initialize: (options: MermaidInitializeOptions) => void + render: (id: string, text: string) => Promise + } + + const mermaid: MermaidApi + export default mermaid +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index e98764fc6f4..c1819f32bbd 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -10,6 +10,7 @@ import { createGitHubSlice } from './slices/github' import { createEditorSlice } from './slices/editor' import { createStatsSlice } from './slices/stats' import { createClaudeUsageSlice } from './slices/claude-usage' +import { createBrowserSlice } from './slices/browser' export const useAppStore = create()((...a) => ({ ...createRepoSlice(...a), @@ -21,7 +22,8 @@ export const useAppStore = create()((...a) => ({ ...createGitHubSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), - ...createClaudeUsageSlice(...a) + ...createClaudeUsageSlice(...a), + ...createBrowserSlice(...a) })) export type { AppState } from './types' diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts new file mode 100644 index 00000000000..7a6020657e3 --- /dev/null +++ b/src/renderer/src/store/slices/browser.ts @@ -0,0 +1,380 @@ +/* eslint-disable max-lines */ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { BrowserLoadError, BrowserTab, WorkspaceSessionState } from '../../../../shared/types' +import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' + +type CreateBrowserTabOptions = { + activate?: boolean + title?: string +} + +type BrowserTabPageState = { + title?: string + loading?: boolean + faviconUrl?: string | null + canGoBack?: boolean + canGoForward?: boolean + loadError?: BrowserLoadError | null +} + +export type BrowserSlice = { + browserTabsByWorktree: Record + activeBrowserTabId: string | null + activeBrowserTabIdByWorktree: Record + createBrowserTab: ( + worktreeId: string, + url: string, + options?: CreateBrowserTabOptions + ) => BrowserTab + closeBrowserTab: (tabId: string) => void + setActiveBrowserTab: (tabId: string) => void + updateBrowserTabPageState: (tabId: string, updates: BrowserTabPageState) => void + setBrowserTabUrl: (tabId: string, url: string) => void + hydrateBrowserSession: (session: WorkspaceSessionState) => void +} + +function normalizeUrl(url: string): string { + const trimmed = url.trim() + if (trimmed.length === 0) { + return 'about:blank' + } + return trimmed +} + +function getFallbackTabTypeForWorktree( + worktreeId: string, + openFiles: AppState['openFiles'], + terminalTabsByWorktree: AppState['tabsByWorktree'], + browserTabsByWorktree?: AppState['browserTabsByWorktree'] +): AppState['activeTabType'] { + if (openFiles.some((file) => file.worktreeId === worktreeId)) { + return 'editor' + } + if ((browserTabsByWorktree?.[worktreeId] ?? []).length > 0) { + return 'browser' + } + if ((terminalTabsByWorktree[worktreeId] ?? []).length > 0) { + return 'terminal' + } + return 'terminal' +} + +export const createBrowserSlice: StateCreator = (set) => ({ + browserTabsByWorktree: {}, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + + createBrowserTab: (worktreeId, url, options) => { + const id = globalThis.crypto.randomUUID() + const now = Date.now() + const normalizedUrl = normalizeUrl(url) + let browserTab!: BrowserTab + set((s) => { + const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] + browserTab = { + id, + worktreeId, + url: normalizedUrl, + title: options?.title ?? normalizedUrl, + // Why: blank tabs mount a parked/inert guest surface first. Marking + // them as loading at creation time makes every about:blank tab flash + // the browser loading dot even when no navigation was requested. + // Real navigations still flip loading via the browser pane events. + loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: now + } + + const nextTabBarOrder = (() => { + const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] + const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = s.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((f) => f.id) + const browserIds = existingTabs.map((tab) => tab.id) + const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) + const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) + const inBase = new Set(base) + for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) { + if (!inBase.has(entryId)) { + base.push(entryId) + inBase.add(entryId) + } + } + base.push(id) + return base + })() + + const shouldActivate = options?.activate ?? true + const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [worktreeId]: [...existingTabs, browserTab] + }, + tabBarOrderByWorktree: { + ...s.tabBarOrderByWorktree, + [worktreeId]: nextTabBarOrder + }, + activeBrowserTabId: shouldActivate ? id : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [worktreeId]: shouldActivate ? id : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) + }, + // Why: browser tabs live in the same visual strip as terminals and editors. + // Creating one should immediately select the browser surface for that + // worktree, but only the active worktree is allowed to drive Orca's + // global visible surface. Background worktrees keep their per-worktree + // browser selection without stealing the foreground pane. + activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, + activeTabTypeByWorktree: shouldActivate + ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } + : s.activeTabTypeByWorktree + } + }) + return browserTab + }, + + closeBrowserTab: (tabId) => + set((s) => { + let owningWorktreeId: string | null = null + const nextBrowserTabsByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const filtered = tabs.filter((tab) => tab.id !== tabId) + if (filtered.length !== tabs.length) { + owningWorktreeId = worktreeId + } + if (filtered.length > 0) { + nextBrowserTabsByWorktree[worktreeId] = filtered + } + } + if (!owningWorktreeId) { + return s + } + + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] + if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { + nextActiveBrowserTabIdByWorktree[owningWorktreeId] = remainingBrowserTabs[0]?.id ?? null + } + + const nextTabBarOrder = { + ...s.tabBarOrderByWorktree, + [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( + (entryId) => entryId !== tabId + ) + } + + const isActiveTabInOwningWorktree = + s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + let nextActiveTabType = s.activeTabType + if (remainingBrowserTabs.length === 0) { + const fallbackTabType = getFallbackTabTypeForWorktree( + owningWorktreeId, + s.openFiles, + s.tabsByWorktree + ) + nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType + if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { + // Why: the per-worktree restore map and the global active surface must + // stay in lockstep. Leaving activeTabType at "browser" after the last + // browser tab closes makes the workspace point at a surface that no + // longer exists, which later renders as a blank body until another + // caller repairs state opportunistically. + nextActiveTabType = fallbackTabType + } + } + + return { + browserTabsByWorktree: nextBrowserTabsByWorktree, + activeBrowserTabId: + s.activeBrowserTabId === tabId + ? (remainingBrowserTabs[0]?.id ?? null) + : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrder, + activeTabType: nextActiveTabType, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree + } + }), + + setActiveBrowserTab: (tabId) => + set((s) => { + const browserTab = Object.values(s.browserTabsByWorktree) + .flat() + .find((tab) => tab.id === tabId) + if (!browserTab) { + return s + } + return { + activeBrowserTabId: tabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [browserTab.worktreeId]: tabId + }, + activeTabType: 'browser', + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [browserTab.worktreeId]: 'browser' + } + } + }), + + updateBrowserTabPageState: (tabId, updates) => + set((s) => ({ + browserTabsByWorktree: Object.fromEntries( + Object.entries(s.browserTabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => + tab.id === tabId + ? { + ...tab, + title: updates.title ?? tab.title, + loading: updates.loading ?? tab.loading, + faviconUrl: + updates.faviconUrl === undefined ? tab.faviconUrl : updates.faviconUrl, + canGoBack: updates.canGoBack ?? tab.canGoBack, + canGoForward: updates.canGoForward ?? tab.canGoForward, + loadError: updates.loadError === undefined ? tab.loadError : updates.loadError + } + : tab + ) + ]) + ) + })), + + setBrowserTabUrl: (tabId, url) => + set((s) => ({ + browserTabsByWorktree: Object.fromEntries( + Object.entries(s.browserTabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => + tab.id === tabId + ? { + ...tab, + url: normalizeUrl(url), + loading: true, + loadError: null + } + : tab + ) + ]) + ) + })), + + hydrateBrowserSession: (session) => + set((s) => { + const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} + const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {} + const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {} + const validWorktreeIds = new Set( + Object.values(s.worktreesByRepo) + .flat() + .map((worktree) => worktree.id) + ) + + const browserTabsByWorktree: Record = Object.fromEntries( + Object.entries(persistedTabsByWorktree) + .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) + .map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => ({ + ...tab, + url: normalizeUrl(tab.url), + loading: false, + loadError: tab.loadError ?? null + })) + ]) + .filter(([, tabs]) => (tabs as BrowserTab[]).length > 0) + ) + + const validBrowserTabIds = new Set( + Object.values(browserTabsByWorktree) + .flat() + .map((tab) => tab.id) + ) + + const activeBrowserTabIdByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(browserTabsByWorktree)) { + const persistedTabId = persistedActiveBrowserTabIdByWorktree[worktreeId] + activeBrowserTabIdByWorktree[worktreeId] = + persistedTabId && validBrowserTabIds.has(persistedTabId) + ? persistedTabId + : (tabs[0]?.id ?? null) + } + + const activeWorktreeId = s.activeWorktreeId + const activeBrowserTabId = + activeWorktreeId && activeBrowserTabIdByWorktree[activeWorktreeId] + ? activeBrowserTabIdByWorktree[activeWorktreeId] + : null + + // Why: hydrateEditorSession may have returned early (no editor files), + // leaving activeTabTypeByWorktree as {}. We must merge in the 'browser' + // entries from the persisted session, otherwise setActiveWorktree will + // default to 'terminal' when switching to a worktree whose last-active + // tab was a browser tab — causing a blank screen. + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + for (const worktreeId of validWorktreeIds) { + const hasBrowserTabs = (browserTabsByWorktree[worktreeId] ?? []).length > 0 + if ( + persistedActiveTabTypeByWorktree[worktreeId] === 'browser' && + hasBrowserTabs && + !nextActiveTabTypeByWorktree[worktreeId] + ) { + // Why: browser hydration runs after editor hydration and owns only the + // browser-visible restore path. Keep browser tab restores intact when + // the persisted session still has a valid browser tab for that worktree. + nextActiveTabTypeByWorktree[worktreeId] = 'browser' + continue + } + if (nextActiveTabTypeByWorktree[worktreeId] === 'browser' && !hasBrowserTabs) { + // Why: older/broken sessions can retain "browser" as the remembered + // surface for a worktree after its browser tabs were closed. Leaving + // that stale marker behind makes Terminal render the browser surface + // with no matching tab, which looks like a blank app. + nextActiveTabTypeByWorktree[worktreeId] = getFallbackTabTypeForWorktree( + worktreeId, + s.openFiles, + s.tabsByWorktree, + browserTabsByWorktree + ) + } + } + + const activeTabType = (() => { + if (!activeWorktreeId) { + return s.activeTabType + } + const restoredTabType = nextActiveTabTypeByWorktree[activeWorktreeId] + if (restoredTabType === 'browser' && activeBrowserTabId) { + return 'browser' + } + if ( + restoredTabType === 'editor' && + s.openFiles.some((file) => file.worktreeId === activeWorktreeId) + ) { + return 'editor' + } + return getFallbackTabTypeForWorktree( + activeWorktreeId, + s.openFiles, + s.tabsByWorktree, + browserTabsByWorktree + ) + })() + + return { + browserTabsByWorktree, + activeBrowserTabIdByWorktree, + activeBrowserTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + activeTabType + } + }) +}) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 33fb971d908..8663858f789 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -10,6 +10,9 @@ function createEditorStore(): StoreApi { // eslint-disable-next-line @typescript-eslint/no-explicit-any return createStore()((...args: any[]) => ({ activeWorktreeId: 'wt-1', + browserTabsByWorktree: {}, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, ...createEditorSlice(...(args as Parameters)) })) as unknown as StoreApi } @@ -216,6 +219,80 @@ describe('createEditorSlice editor drafts', () => { expect(store.getState().editorDrafts).toEqual({}) }) + + it('falls back to a browser tab when closing the last editor in the active worktree', () => { + const store = createEditorStore() + + store.setState({ + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'browser-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + }, + activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' } + }) + + store.getState().openFile({ + filePath: '/repo/src/file.ts', + relativePath: 'src/file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + + store.getState().closeFile('/repo/src/file.ts') + + expect(store.getState().activeTabType).toBe('browser') + expect(store.getState().activeBrowserTabId).toBe('browser-1') + }) + + it('falls back to a browser tab when closing all editors in the active worktree', () => { + const store = createEditorStore() + + store.setState({ + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'browser-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + }, + activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' } + }) + + store.getState().openFile({ + filePath: '/repo/src/file.ts', + relativePath: 'src/file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + + store.getState().closeAllFiles() + + expect(store.getState().activeTabType).toBe('browser') + expect(store.getState().activeBrowserTabId).toBe('browser-1') + }) }) describe('createEditorSlice conflict status reconciliation', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index f82c4c0edce..1e1db6f8c8a 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -12,7 +12,8 @@ import type { GitStatusEntry, GitStatusResult, SearchResult, - WorkspaceSessionState + WorkspaceSessionState, + WorkspaceVisibleTabType } from '../../../../shared/types' export type DiffSource = @@ -141,9 +142,9 @@ export type EditorSlice = { openFiles: OpenFile[] activeFileId: string | null activeFileIdByWorktree: Record // worktreeId -> last active file - activeTabTypeByWorktree: Record // worktreeId -> last active tab type - activeTabType: 'terminal' | 'editor' - setActiveTabType: (type: 'terminal' | 'editor') => void + activeTabTypeByWorktree: Record // worktreeId -> last active tab type + activeTabType: WorkspaceVisibleTabType + setActiveTabType: (type: WorkspaceVisibleTabType) => void openFile: (file: Omit, options?: { preview?: boolean }) => void pinFile: (fileId: string) => void closeFile: (fileId: string) => void @@ -526,21 +527,42 @@ export const createEditorSlice: StateCreator = (s } } - // When last editor file for current worktree is closed, switch back to terminal + // Why: editor tabs share a mixed tab strip with browser tabs. Closing the + // last editor in a worktree should reveal an available browser tab before + // falling all the way back to a terminal surface. const activeWorktreeId = s.activeWorktreeId const remainingForWorktree = activeWorktreeId ? newFiles.filter((f) => f.worktreeId === activeWorktreeId) : newFiles - const newActiveTabType = remainingForWorktree.length === 0 ? 'terminal' : s.activeTabType + const browserTabsForWorktree = activeWorktreeId + ? (s.browserTabsByWorktree[activeWorktreeId] ?? []) + : [] + const fallbackBrowserTabId = + activeWorktreeId && browserTabsForWorktree.length > 0 + ? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ?? + browserTabsForWorktree[0]?.id ?? + null) + : s.activeBrowserTabId + const newActiveTabType = + remainingForWorktree.length > 0 + ? s.activeTabType + : browserTabsForWorktree.length > 0 + ? 'browser' + : 'terminal' const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } if (activeWorktreeId && remainingForWorktree.length === 0) { - newActiveTabTypeByWorktree[activeWorktreeId] = 'terminal' + newActiveTabTypeByWorktree[activeWorktreeId] = + browserTabsForWorktree.length > 0 ? 'browser' : 'terminal' } return { openFiles: newFiles, editorDrafts: newEditorDrafts, activeFileId: newActiveId, + activeBrowserTabId: + activeWorktreeId && remainingForWorktree.length === 0 + ? fallbackBrowserTabId + : s.activeBrowserTabId, activeTabType: newActiveTabType, activeFileIdByWorktree: newActiveFileIdByWorktree, activeTabTypeByWorktree: newActiveTabTypeByWorktree, @@ -574,12 +596,20 @@ export const createEditorSlice: StateCreator = (s const newActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } delete newActiveFileIdByWorktree[activeWorktreeId] const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - newActiveTabTypeByWorktree[activeWorktreeId] = 'terminal' + const browserTabsForWorktree = s.browserTabsByWorktree[activeWorktreeId] ?? [] + newActiveTabTypeByWorktree[activeWorktreeId] = + browserTabsForWorktree.length > 0 ? 'browser' : 'terminal' return { openFiles: newFiles, editorDrafts: newEditorDrafts, activeFileId: null, - activeTabType: 'terminal', + activeBrowserTabId: + browserTabsForWorktree.length > 0 + ? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ?? + browserTabsForWorktree[0]?.id ?? + null) + : s.activeBrowserTabId, + activeTabType: browserTabsForWorktree.length > 0 ? 'browser' : 'terminal', markdownViewMode: newMarkdownViewMode, activeFileIdByWorktree: newActiveFileIdByWorktree, activeTabTypeByWorktree: newActiveTabTypeByWorktree, @@ -1296,7 +1326,7 @@ export const createEditorSlice: StateCreator = (s // The file may have been removed due to worktree validation or the // persisted data may reference a stale path. const activeFileExists = activeFileId ? openFiles.some((f) => f.id === activeFileId) : false - const activeTabType = + const activeTabType: WorkspaceVisibleTabType = activeWorktreeId && persistedActiveTabTypeByWorktree[activeWorktreeId] ? persistedActiveTabTypeByWorktree[activeWorktreeId] : 'terminal' @@ -1309,9 +1339,19 @@ export const createEditorSlice: StateCreator = (s ) ) const filteredActiveTabTypeByWorktree = Object.fromEntries( - Object.entries(persistedActiveTabTypeByWorktree).filter(([wId]) => - validWorktreeIds.has(wId) - ) + Object.entries(persistedActiveTabTypeByWorktree).filter(([wId, tabType]) => { + if (!validWorktreeIds.has(wId)) { + return false + } + if (tabType !== 'editor') { + return true + } + // Why: a persisted "editor" surface only makes sense if that + // worktree still restored a concrete active editor file. Otherwise we + // preserve a stale last-active marker that conflicts with browser or + // terminal restore logic for the same worktree. + return Boolean(filteredActiveFileIdByWorktree[wId]) + }) ) return { diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index c12627bff0d..bd710db7de5 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock sonner (imported by repos.ts) @@ -315,4 +316,160 @@ describe('setActiveWorktree', () => { expect(worktree.sortOrder).toBe(123) expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() }) + + it('falls back to the worktree browser tab when the restored editor id belongs to a different worktree', () => { + const store = createTestStore() + const wt1 = 'repo1::/path/wt1' + const wt2 = 'repo1::/path/wt2' + const otherFileId = '/path/wt2/file.ts' + const browserTabId = 'browser-1' + + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: wt2, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + openFiles: [makeOpenFile({ id: otherFileId, worktreeId: wt2 })], + activeFileIdByWorktree: { [wt1]: otherFileId }, + browserTabsByWorktree: { + [wt1]: [ + { + id: browserTabId, + worktreeId: wt1, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + }, + activeBrowserTabIdByWorktree: { [wt1]: browserTabId }, + activeTabTypeByWorktree: { [wt1]: 'editor' } + }) + + store.getState().setActiveWorktree(wt1) + + const s = store.getState() + expect(s.activeWorktreeId).toBe(wt1) + expect(s.activeBrowserTabId).toBe(browserTabId) + expect(s.activeTabType).toBe('browser') + expect(s.activeFileId).toBeNull() + }) + + it('clears stale background browser tab type when closing the last browser tab', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: null, + tabsByWorktree: { + [wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })] + }, + browserTabsByWorktree: { + [wt]: [ + { + id: 'browser-1', + worktreeId: wt, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + }, + activeBrowserTabIdByWorktree: { [wt]: 'browser-1' }, + activeTabTypeByWorktree: { [wt]: 'browser' } + }) + + store.getState().closeBrowserTab('browser-1') + + expect(store.getState().activeTabTypeByWorktree[wt]).toBe('terminal') + expect(store.getState().activeBrowserTabIdByWorktree[wt]).toBeNull() + }) + + it('falls back to editor globally when closing the last active browser tab in a worktree with files', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const fileId = '/path/wt1/src/index.ts' + + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: wt, + activeTabType: 'browser', + openFiles: [makeOpenFile({ id: fileId, worktreeId: wt, filePath: fileId })], + activeFileId: fileId, + activeFileIdByWorktree: { [wt]: fileId }, + activeTabTypeByWorktree: { [wt]: 'browser' }, + browserTabsByWorktree: { + [wt]: [ + { + id: 'browser-1', + worktreeId: wt, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + }, + activeBrowserTabId: 'browser-1', + activeBrowserTabIdByWorktree: { [wt]: 'browser-1' } + }) + + store.getState().closeBrowserTab('browser-1') + + const s = store.getState() + expect(s.activeTabType).toBe('editor') + expect(s.activeTabTypeByWorktree[wt]).toBe('editor') + expect(s.activeFileId).toBe(fileId) + }) + + it('does not switch the global surface when creating a browser tab for a background worktree', () => { + const store = createTestStore() + const activeWt = 'repo1::/path/wt1' + const backgroundWt = 'repo1::/path/wt2' + + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: activeWt, repoId: 'repo1', path: '/path/wt1' }), + makeWorktree({ id: backgroundWt, repoId: 'repo1', path: '/path/wt2' }) + ] + }, + activeWorktreeId: activeWt, + activeTabType: 'terminal', + tabsByWorktree: { + [activeWt]: [makeTab({ id: 'terminal-1', worktreeId: activeWt })], + [backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })] + } + }) + + const browserTab = store + .getState() + .createBrowserTab(backgroundWt, 'https://example.com', { activate: true }) + + const s = store.getState() + expect(s.activeTabType).toBe('terminal') + expect(s.activeTabTypeByWorktree[backgroundWt]).toBe('browser') + expect(s.activeBrowserTabIdByWorktree[backgroundWt]).toBe(browserTab.id) + }) }) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 593df184685..138cc8d5f46 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import type { Worktree, TerminalTab, TerminalLayoutSnapshot } from '../../../../shared/types' +import type { + BrowserTab, + TerminalLayoutSnapshot, + TerminalTab, + Worktree +} from '../../../../shared/types' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -73,6 +78,7 @@ import { createGitHubSlice } from './github' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createClaudeUsageSlice } from './claude-usage' +import { createBrowserSlice } from './browser' function createTestStore() { return create()((...a) => ({ @@ -85,7 +91,8 @@ function createTestStore() { ...createGitHubSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), - ...createClaudeUsageSlice(...a) + ...createClaudeUsageSlice(...a), + ...createBrowserSlice(...a) })) } @@ -128,6 +135,21 @@ function makeLayout(): TerminalLayoutSnapshot { return { root: null, activeLeafId: null, expandedLeafId: null } } +function makeBrowserTab( + overrides: Partial & { id: string; worktreeId: string; url: string } +): BrowserTab { + return { + title: overrides.url, + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: Date.now(), + ...overrides + } +} + // ─── Tests ──────────────────────────────────────────────────────────── describe('removeRepo cascade', () => { @@ -284,6 +306,204 @@ describe('hydrateWorkspaceSession', () => { }) }) +describe('hydrateBrowserSession', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back to the first valid browser tab when the persisted active browser tab is missing', () => { + const store = createTestStore() + const validWt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: validWt + }) + + store.getState().hydrateBrowserSession({ + activeRepoId: 'repo1', + activeWorktreeId: validWt, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + [validWt]: [ + makeBrowserTab({ id: 'browser-1', worktreeId: validWt, url: 'https://example.com' }), + makeBrowserTab({ id: 'browser-2', worktreeId: validWt, url: 'https://openai.com' }) + ] + }, + activeBrowserTabIdByWorktree: { + [validWt]: 'missing-browser-id' + }, + activeTabTypeByWorktree: { + [validWt]: 'browser' + } + }) + + const s = store.getState() + expect(s.browserTabsByWorktree[validWt]).toHaveLength(2) + expect(s.activeBrowserTabIdByWorktree[validWt]).toBe('browser-1') + expect(s.activeBrowserTabId).toBe('browser-1') + }) + + it('restores activeTabTypeByWorktree for browser worktrees when hydrateEditorSession was a no-op', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: wt, + // Simulate hydrateEditorSession returning {} (no editor files) — + // activeTabTypeByWorktree stays at the initial empty object + activeTabTypeByWorktree: {} + }) + + store.getState().hydrateBrowserSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + [wt]: [makeBrowserTab({ id: 'browser-1', worktreeId: wt, url: 'https://example.com' })] + }, + activeBrowserTabIdByWorktree: { [wt]: 'browser-1' }, + activeTabTypeByWorktree: { [wt]: 'browser' } + }) + + const s = store.getState() + // hydrateBrowserSession must merge 'browser' entries into activeTabTypeByWorktree + // so setActiveWorktree doesn't default to 'terminal' and cause a blank screen + expect(s.activeTabTypeByWorktree[wt]).toBe('browser') + expect(s.activeTabType).toBe('browser') + expect(s.activeBrowserTabId).toBe('browser-1') + }) + + it('does not overwrite existing activeTabTypeByWorktree entries from hydrateEditorSession', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: wt, + // Simulate hydrateEditorSession having already set this to 'editor' + activeTabTypeByWorktree: { [wt]: 'editor' } + }) + + store.getState().hydrateBrowserSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + [wt]: [makeBrowserTab({ id: 'browser-1', worktreeId: wt, url: 'https://example.com' })] + }, + activeBrowserTabIdByWorktree: { [wt]: 'browser-1' }, + activeTabTypeByWorktree: { [wt]: 'browser' } + }) + + const s = store.getState() + // The existing 'editor' entry set by hydrateEditorSession must not be overwritten + expect(s.activeTabTypeByWorktree[wt]).toBe('editor') + }) + + it('drops browser tabs for invalid worktrees', () => { + const store = createTestStore() + const validWt = 'repo1::/path/wt1' + const invalidWt = 'repo1::/path/gone' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: validWt + }) + + store.getState().hydrateBrowserSession({ + activeRepoId: 'repo1', + activeWorktreeId: validWt, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: { + [validWt]: [ + makeBrowserTab({ id: 'browser-1', worktreeId: validWt, url: 'https://example.com' }) + ], + [invalidWt]: [ + makeBrowserTab({ id: 'browser-bad', worktreeId: invalidWt, url: 'https://bad.invalid' }) + ] + }, + activeBrowserTabIdByWorktree: { + [validWt]: 'browser-1', + [invalidWt]: 'browser-bad' + } + }) + + const s = store.getState() + expect(s.browserTabsByWorktree[validWt]).toHaveLength(1) + expect(s.browserTabsByWorktree[invalidWt]).toBeUndefined() + expect(s.activeBrowserTabIdByWorktree[invalidWt]).toBeUndefined() + }) + + it('normalizes stale browser tab-type restores when the worktree has no browser tabs', () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] + }, + activeWorktreeId: wt, + tabsByWorktree: { + [wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })] + }, + activeTabTypeByWorktree: { [wt]: 'browser' }, + activeTabType: 'browser' + }) + + store.getState().hydrateBrowserSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt, + activeTabId: 'terminal-1', + tabsByWorktree: { + [wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })] + }, + terminalLayoutsByTabId: {}, + browserTabsByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: { [wt]: 'browser' } + }) + + const s = store.getState() + expect(s.activeTabTypeByWorktree[wt]).toBe('terminal') + expect(s.activeTabType).toBe('terminal') + expect(s.activeBrowserTabIdByWorktree[wt]).toBeUndefined() + expect(s.activeBrowserTabId).toBeNull() + }) +}) + describe('terminal slice behaviors', () => { beforeEach(() => { vi.clearAllMocks() @@ -726,6 +946,7 @@ describe('hydrateEditorSession', () => { expect(s.openFiles).toHaveLength(1) expect(s.activeFileId).toBeNull() expect(s.activeTabType).toBe('terminal') + expect(s.activeTabTypeByWorktree[wt]).toBeUndefined() }) it('filters out files for deleted worktrees', () => { diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index d60f8ad34da..779938c4b3e 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -18,6 +18,7 @@ import { createGitHubSlice } from './github' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createClaudeUsageSlice } from './claude-usage' +import { createBrowserSlice } from './browser' export const TEST_REPO = { id: 'repo1', @@ -38,7 +39,8 @@ export function createTestStore() { ...createGitHubSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), - ...createClaudeUsageSlice(...a) + ...createClaudeUsageSlice(...a), + ...createBrowserSlice(...a) })) } diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index e05289ac054..c15cf2971df 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -73,6 +73,7 @@ import { createGitHubSlice } from './github' import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createClaudeUsageSlice } from './claude-usage' +import { createBrowserSlice } from './browser' const WT = 'repo1::/tmp/feature' @@ -87,7 +88,8 @@ function createTestStore() { ...createGitHubSlice(...a), ...createEditorSlice(...a), ...createStatsSlice(...a), - ...createClaudeUsageSlice(...a) + ...createClaudeUsageSlice(...a), + ...createBrowserSlice(...a) })) } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index b38fd37407a..1e61bf9ac80 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { Worktree } from '../../../../shared/types' +import type { Worktree, WorkspaceVisibleTabType } from '../../../../shared/types' import { findWorktreeById, applyWorktreeUpdates, @@ -124,14 +124,19 @@ export const createWorktreeSlice: StateCreator delete nextDeleteState[worktreeId] // Clean up editor files belonging to this worktree const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId) + const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } + delete nextBrowserTabsByWorktree[worktreeId] const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } delete nextActiveFileIdByWorktree[worktreeId] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + delete nextActiveBrowserTabIdByWorktree[worktreeId] const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } delete nextActiveTabTypeByWorktree[worktreeId] // If the active file belonged to the removed worktree, clear it const activeFileCleared = s.activeFileId ? s.openFiles.some((f) => f.id === s.activeFileId && f.worktreeId === worktreeId) : false + const removedActiveWorktree = s.activeWorktreeId === worktreeId return { worktreesByRepo: next, tabsByWorktree: nextTabs, @@ -146,13 +151,16 @@ export const createWorktreeSlice: StateCreator delete nextSearch[worktreeId] return nextSearch })(), - activeWorktreeId: s.activeWorktreeId === worktreeId ? null : s.activeWorktreeId, + activeWorktreeId: removedActiveWorktree ? null : s.activeWorktreeId, activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId, openFiles: newOpenFiles, + browserTabsByWorktree: nextBrowserTabsByWorktree, activeFileIdByWorktree: nextActiveFileIdByWorktree, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, activeTabTypeByWorktree: nextActiveTabTypeByWorktree, activeFileId: activeFileCleared ? null : s.activeFileId, - activeTabType: activeFileCleared ? 'terminal' : s.activeTabType, + activeBrowserTabId: removedActiveWorktree ? null : s.activeBrowserTabId, + activeTabType: removedActiveWorktree || activeFileCleared ? 'terminal' : s.activeTabType, sortEpoch: s.sortEpoch + 1 } }) @@ -290,22 +298,47 @@ export const createWorktreeSlice: StateCreator // Restore per-worktree editor state const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null + const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[worktreeId] ?? null const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal' // Verify the restored file still exists in openFiles const fileStillOpen = restoredFileId - ? s.openFiles.some((f) => f.id === restoredFileId) + ? s.openFiles.some((f) => f.id === restoredFileId && f.worktreeId === worktreeId) + : false + const browserTabs = s.browserTabsByWorktree[worktreeId] ?? [] + const browserTabStillOpen = restoredBrowserTabId + ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) : false // If restored file is gone, fall back to another open file for this worktree let activeFileId: string | null - let activeTabType: 'terminal' | 'editor' - if (fileStillOpen) { + let activeBrowserTabId: string | null + let activeTabType: WorkspaceVisibleTabType + if (restoredTabType === 'browser' && browserTabStillOpen) { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = restoredBrowserTabId + activeTabType = 'browser' + } else if (restoredTabType === 'editor' && fileStillOpen) { activeFileId = restoredFileId - activeTabType = restoredTabType + activeBrowserTabId = browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + activeTabType = 'editor' + } else if (browserTabStillOpen) { + activeFileId = null + activeBrowserTabId = restoredBrowserTabId + activeTabType = 'browser' + } else if (fileStillOpen) { + activeFileId = restoredFileId + activeBrowserTabId = browserTabs[0]?.id ?? null + activeTabType = 'editor' } else { const fallbackFile = s.openFiles.find((f) => f.worktreeId === worktreeId) + const fallbackBrowserTab = browserTabs[0] ?? null activeFileId = fallbackFile?.id ?? null - activeTabType = fallbackFile ? 'editor' : 'terminal' + activeBrowserTabId = browserTabStillOpen + ? restoredBrowserTabId + : (fallbackBrowserTab?.id ?? null) + activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' } // Why: restore the last-active terminal tab for this worktree so the @@ -320,7 +353,9 @@ export const createWorktreeSlice: StateCreator return { activeWorktreeId: worktreeId, activeFileId, + activeBrowserTabId, activeTabType, + activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: activeTabType }, activeTabId, worktreesByRepo: applyWorktreeUpdates( s.worktreesByRepo, diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index 199609fd476..1949bb6b6cf 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -8,6 +8,7 @@ import type { GitHubSlice } from './slices/github' import type { EditorSlice } from './slices/editor' import type { StatsSlice } from './slices/stats' import type { ClaudeUsageSlice } from './slices/claude-usage' +import type { BrowserSlice } from './slices/browser' export type AppState = RepoSlice & WorktreeSlice & @@ -18,4 +19,5 @@ export type AppState = RepoSlice & GitHubSlice & EditorSlice & StatsSlice & - ClaudeUsageSlice + ClaudeUsageSlice & + BrowserSlice diff --git a/src/shared/browser-url.test.ts b/src/shared/browser-url.test.ts new file mode 100644 index 00000000000..69bda4efe8e --- /dev/null +++ b/src/shared/browser-url.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { ORCA_BROWSER_BLANK_URL } from './constants' +import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl } from './browser-url' + +describe('browser-url helpers', () => { + it('normalizes manual local-dev inputs to http', () => { + expect(normalizeBrowserNavigationUrl('localhost:3000')).toBe('http://localhost:3000/') + expect(normalizeBrowserNavigationUrl('127.0.0.1:5173')).toBe('http://127.0.0.1:5173/') + }) + + it('keeps normal web URLs and blank tabs in the allowed set', () => { + expect(normalizeBrowserNavigationUrl('https://example.com')).toBe('https://example.com/') + expect(normalizeBrowserNavigationUrl('')).toBe(ORCA_BROWSER_BLANK_URL) + expect(normalizeBrowserNavigationUrl('about:blank')).toBe(ORCA_BROWSER_BLANK_URL) + }) + + it('rejects non-web schemes for in-app navigation', () => { + expect(normalizeBrowserNavigationUrl('file:///etc/passwd')).toBeNull() + expect(normalizeBrowserNavigationUrl('javascript:alert(1)')).toBeNull() + expect(normalizeExternalBrowserUrl('about:blank')).toBeNull() + }) +}) diff --git a/src/shared/browser-url.ts b/src/shared/browser-url.ts new file mode 100644 index 00000000000..42aae7b21eb --- /dev/null +++ b/src/shared/browser-url.ts @@ -0,0 +1,35 @@ +import { ORCA_BROWSER_BLANK_URL } from './constants' + +const LOCAL_ADDRESS_PATTERN = + /^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:\/.*)?$/i + +export function normalizeBrowserNavigationUrl(rawUrl: string): string | null { + const trimmed = rawUrl.trim() + if (trimmed.length === 0 || trimmed === 'about:blank' || trimmed === ORCA_BROWSER_BLANK_URL) { + return ORCA_BROWSER_BLANK_URL + } + + if (LOCAL_ADDRESS_PATTERN.test(trimmed)) { + try { + return new URL(`http://${trimmed}`).toString() + } catch { + return null + } + } + + try { + const parsed = new URL(trimmed) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null + } catch { + try { + return new URL(`https://${trimmed}`).toString() + } catch { + return null + } + } +} + +export function normalizeExternalBrowserUrl(rawUrl: string): string | null { + const normalized = normalizeBrowserNavigationUrl(rawUrl) + return normalized === ORCA_BROWSER_BLANK_URL ? null : normalized +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 29ce3fc0003..ed9e1d2a0bb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -10,6 +10,12 @@ import type { import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' export const SCHEMA_VERSION = 1 +export const ORCA_BROWSER_PARTITION = 'persist:orca-browser' +// Why: blank browser tabs must start from an inert guest URL that does not +// navigate the privileged main window to about:blank. Renderer and main both +// need the exact same value so the attach policy can allow only this one safe +// data URL while still rejecting arbitrary renderer-provided data URLs. +export const ORCA_BROWSER_BLANK_URL = 'data:text/html,' // Pick a default terminal font that is likely to exist on the current OS. // buildFontFamily() adds the full cross-platform fallback chain, so this only @@ -95,6 +101,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // focus-follows-mouse never happens unexpectedly. terminalFocusFollowsMouse: false, terminalScrollbackBytes: 10_000_000, + openLinksInApp: false, rightSidebarOpenByDefault: true, notifications: getDefaultNotificationSettings(), diffDefaultView: 'inline', @@ -152,6 +159,8 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState { tabsByWorktree: {}, terminalLayoutsByTabId: {}, openFilesByWorktree: {}, + browserTabsByWorktree: {}, + activeBrowserTabIdByWorktree: {}, activeFileIdByWorktree: {}, activeTabTypeByWorktree: {} } diff --git a/src/shared/types.ts b/src/shared/types.ts index 1b51077240a..7e0f152d49b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -56,7 +56,9 @@ export type WorktreeMeta = { } // ─── Unified Tab ──────────────────────────────────────────────────── -export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' +export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser' + +export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' export type Tab = { id: string // UUID for terminals, filePath for editors (preserves current convention) @@ -93,6 +95,25 @@ export type TerminalTab = { generation?: number } +export type BrowserLoadError = { + code: number + description: string + validatedUrl: string +} + +export type BrowserTab = { + id: string + worktreeId: string + url: string + title: string + loading: boolean + faviconUrl: string | null + canGoBack: boolean + canGoForward: boolean + loadError: BrowserLoadError | null + createdAt: number +} + export type TerminalPaneSplitDirection = 'vertical' | 'horizontal' export type TerminalPaneLayoutNode = @@ -147,8 +168,12 @@ export type WorkspaceSessionState = { openFilesByWorktree?: Record /** Per-worktree active editor file ID (filePath) at shutdown. */ activeFileIdByWorktree?: Record - /** Per-worktree active tab type (terminal vs editor) at shutdown. */ - activeTabTypeByWorktree?: Record + /** Persisted browser tabs, keyed by worktree ID. */ + browserTabsByWorktree?: Record + /** Per-worktree active browser tab ID at shutdown. */ + activeBrowserTabIdByWorktree?: Record + /** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */ + activeTabTypeByWorktree?: Record /** Per-worktree last-active terminal tab ID at shutdown. */ activeTabIdByWorktree?: Record /** Unified tab model — present when saved by a build that includes TabsSlice. @@ -314,6 +339,10 @@ export type GlobalSettings = { terminalDividerThicknessPx: number terminalFocusFollowsMouse: boolean terminalScrollbackBytes: number + /** Why: opening arbitrary links inside Orca uses an isolated guest browser surface. + * The setting stays opt-in so existing workflows continue to use the system browser + * until the user explicitly wants worktree-scoped in-app browsing. */ + openLinksInApp: boolean rightSidebarOpenByDefault: boolean diffDefaultView: 'inline' | 'side-by-side' notifications: NotificationSettings