mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Add in-app browser tabs (#430)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, number>()
|
||||
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
|
||||
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly policyAttachedGuestIds = new Set<number>()
|
||||
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<boolean> {
|
||||
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()
|
||||
+7
-1
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,<script>alert(1)</script>' } 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(),
|
||||
|
||||
@@ -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<string, unknown>).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)
|
||||
|
||||
Vendored
+294
@@ -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<void>
|
||||
unregisterGuest: (args: { browserTabId: string }) => Promise<void>
|
||||
openDevTools: (args: { browserTabId: string }) => Promise<boolean>
|
||||
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<PreflightStatus>
|
||||
}
|
||||
|
||||
export type StatsApi = {
|
||||
getSummary: () => Promise<StatsSummary>
|
||||
}
|
||||
|
||||
export type ClaudeUsageApi = {
|
||||
getScanState: () => Promise<ClaudeUsageScanState>
|
||||
setEnabled: (args: { enabled: boolean }) => Promise<ClaudeUsageScanState>
|
||||
refresh: (args?: { force?: boolean }) => Promise<ClaudeUsageScanState>
|
||||
getSummary: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
}) => Promise<ClaudeUsageSummary>
|
||||
getDaily: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
}) => Promise<ClaudeUsageDailyPoint[]>
|
||||
getBreakdown: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
kind: ClaudeUsageBreakdownKind
|
||||
}) => Promise<ClaudeUsageBreakdownRow[]>
|
||||
getRecentSessions: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
limit?: number
|
||||
}) => Promise<ClaudeUsageSessionRow[]>
|
||||
}
|
||||
|
||||
export type PreloadApi = {
|
||||
repos: {
|
||||
list: () => Promise<Repo[]>
|
||||
add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise<Repo>
|
||||
remove: (args: { repoId: string }) => Promise<void>
|
||||
update: (args: {
|
||||
repoId: string
|
||||
updates: Partial<
|
||||
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef' | 'kind'>
|
||||
>
|
||||
}) => Promise<Repo>
|
||||
pickFolder: () => Promise<string | null>
|
||||
pickDirectory: () => Promise<string | null>
|
||||
clone: (args: { url: string; destination: string }) => Promise<Repo>
|
||||
cloneAbort: () => Promise<void>
|
||||
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
|
||||
getGitUsername: (args: { repoId: string }) => Promise<string>
|
||||
getBaseRefDefault: (args: { repoId: string }) => Promise<string>
|
||||
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
|
||||
onChanged: (callback: () => void) => () => void
|
||||
}
|
||||
worktrees: {
|
||||
list: (args: { repoId: string }) => Promise<Worktree[]>
|
||||
listAll: () => Promise<Worktree[]>
|
||||
create: (args: {
|
||||
repoId: string
|
||||
name: string
|
||||
baseBranch?: string
|
||||
setupDecision?: 'inherit' | 'run' | 'skip'
|
||||
}) => Promise<CreateWorktreeResult>
|
||||
remove: (args: { worktreeId: string; force?: boolean }) => Promise<void>
|
||||
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
|
||||
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
|
||||
onChanged: (callback: (data: { repoId: string }) => void) => () => void
|
||||
}
|
||||
pty: {
|
||||
spawn: (opts: {
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
}) => Promise<{ id: string }>
|
||||
write: (id: string, data: string) => void
|
||||
resize: (id: string, cols: number, rows: number) => void
|
||||
kill: (id: string) => Promise<void>
|
||||
hasChildProcesses: (id: string) => Promise<boolean>
|
||||
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<PRInfo | null>
|
||||
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
|
||||
listIssues: (args: { repoPath: string; limit?: number }) => Promise<IssueInfo[]>
|
||||
prChecks: (args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
headSha?: string
|
||||
noCache?: boolean
|
||||
}) => Promise<PRCheckDetail[]>
|
||||
prComments: (args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
noCache?: boolean
|
||||
}) => Promise<PRComment[]>
|
||||
resolveReviewThread: (args: {
|
||||
repoPath: string
|
||||
threadId: string
|
||||
resolve: boolean
|
||||
}) => Promise<boolean>
|
||||
updatePRTitle: (args: { repoPath: string; prNumber: number; title: string }) => Promise<boolean>
|
||||
mergePR: (args: {
|
||||
repoPath: string
|
||||
prNumber: number
|
||||
method?: 'merge' | 'squash' | 'rebase'
|
||||
}) => Promise<{ ok: true } | { ok: false; error: string }>
|
||||
checkOrcaStarred: () => Promise<boolean | null>
|
||||
starOrca: () => Promise<boolean>
|
||||
}
|
||||
settings: {
|
||||
get: () => Promise<GlobalSettings>
|
||||
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
|
||||
listFonts: () => Promise<string[]>
|
||||
}
|
||||
cli: {
|
||||
getInstallStatus: () => Promise<CliInstallStatus>
|
||||
install: () => Promise<CliInstallStatus>
|
||||
remove: () => Promise<CliInstallStatus>
|
||||
}
|
||||
preflight: PreflightApi
|
||||
notifications: {
|
||||
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
|
||||
openSystemSettings: () => Promise<void>
|
||||
}
|
||||
shell: {
|
||||
openPath: (path: string) => Promise<void>
|
||||
openUrl: (url: string) => Promise<void>
|
||||
openFilePath: (path: string) => Promise<void>
|
||||
openFileUri: (uri: string) => Promise<void>
|
||||
pathExists: (path: string) => Promise<boolean>
|
||||
pickImage: () => Promise<string | null>
|
||||
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
|
||||
}
|
||||
browser: BrowserApi
|
||||
hooks: {
|
||||
check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }>
|
||||
}
|
||||
cache: {
|
||||
getGitHub: () => Promise<{
|
||||
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
|
||||
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
|
||||
}>
|
||||
setGitHub: (args: {
|
||||
cache: {
|
||||
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
|
||||
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
|
||||
}
|
||||
}) => Promise<void>
|
||||
}
|
||||
session: {
|
||||
get: () => Promise<WorkspaceSessionState>
|
||||
set: (args: WorkspaceSessionState) => Promise<void>
|
||||
setSync: (args: WorkspaceSessionState) => void
|
||||
}
|
||||
updater: {
|
||||
getVersion: () => Promise<string>
|
||||
getStatus: () => Promise<UpdateStatus>
|
||||
check: () => Promise<void>
|
||||
download: () => Promise<void>
|
||||
quitAndInstall: () => Promise<void>
|
||||
onStatus: (callback: (status: UpdateStatus) => void) => () => void
|
||||
}
|
||||
stats: StatsApi
|
||||
claudeUsage: ClaudeUsageApi
|
||||
fs: {
|
||||
readDir: (args: { dirPath: string }) => Promise<DirEntry[]>
|
||||
readFile: (args: {
|
||||
filePath: string
|
||||
}) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }>
|
||||
writeFile: (args: { filePath: string; content: string }) => Promise<void>
|
||||
createFile: (args: { filePath: string }) => Promise<void>
|
||||
createDir: (args: { dirPath: string }) => Promise<void>
|
||||
rename: (args: { oldPath: string; newPath: string }) => Promise<void>
|
||||
deletePath: (args: { targetPath: string }) => Promise<void>
|
||||
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
|
||||
stat: (args: {
|
||||
filePath: string
|
||||
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
|
||||
listFiles: (args: { rootPath: string }) => Promise<string[]>
|
||||
search: (args: SearchOptions) => Promise<SearchResult>
|
||||
}
|
||||
git: {
|
||||
status: (args: { worktreePath: string }) => Promise<{ entries: GitStatusEntry[] }>
|
||||
conflictOperation: (args: { worktreePath: string }) => Promise<GitConflictOperation>
|
||||
diff: (args: {
|
||||
worktreePath: string
|
||||
filePath: string
|
||||
staged: boolean
|
||||
}) => Promise<GitDiffResult>
|
||||
branchCompare: (args: {
|
||||
worktreePath: string
|
||||
baseRef: string
|
||||
}) => Promise<GitBranchCompareResult>
|
||||
branchDiff: (args: {
|
||||
worktreePath: string
|
||||
compare: {
|
||||
baseRef: string
|
||||
baseOid: string
|
||||
headOid: string
|
||||
mergeBase: string
|
||||
}
|
||||
filePath: string
|
||||
oldPath?: string
|
||||
}) => Promise<GitDiffResult>
|
||||
stage: (args: { worktreePath: string; filePath: string }) => Promise<void>
|
||||
bulkStage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
|
||||
unstage: (args: { worktreePath: string; filePath: string }) => Promise<void>
|
||||
bulkUnstage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
|
||||
discard: (args: { worktreePath: string; filePath: string }) => Promise<void>
|
||||
remoteFileUrl: (args: {
|
||||
worktreePath: string
|
||||
relativePath: string
|
||||
line: number
|
||||
}) => Promise<string | null>
|
||||
}
|
||||
ui: {
|
||||
get: () => Promise<PersistedUIState>
|
||||
set: (args: Partial<PersistedUIState>) => Promise<void>
|
||||
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<string>
|
||||
writeClipboardText: (text: string) => Promise<void>
|
||||
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<RuntimeStatus>
|
||||
getStatus: () => Promise<RuntimeStatus>
|
||||
}
|
||||
}
|
||||
Vendored
+3
-201
@@ -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<Repo[]>
|
||||
@@ -142,174 +108,10 @@ type ShellApi = {
|
||||
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
|
||||
}
|
||||
|
||||
type HooksApi = {
|
||||
check: (args: { repoId: string }) => Promise<{ hasHooks: boolean; hooks: OrcaHooks | null }>
|
||||
}
|
||||
|
||||
type CacheApi = {
|
||||
getGitHub: () => Promise<{
|
||||
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
|
||||
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
|
||||
}>
|
||||
setGitHub: (args: {
|
||||
cache: {
|
||||
pr: Record<string, { data: PRInfo | null; fetchedAt: number }>
|
||||
issue: Record<string, { data: IssueInfo | null; fetchedAt: number }>
|
||||
}
|
||||
}) => Promise<void>
|
||||
}
|
||||
|
||||
type SessionApi = {
|
||||
get: () => Promise<WorkspaceSessionState>
|
||||
set: (args: WorkspaceSessionState) => Promise<void>
|
||||
/** Synchronous session save for beforeunload — blocks until flushed to disk. */
|
||||
setSync: (args: WorkspaceSessionState) => void
|
||||
}
|
||||
|
||||
type UpdaterApi = {
|
||||
getVersion: () => Promise<string>
|
||||
getStatus: () => Promise<UpdateStatus>
|
||||
check: () => Promise<void>
|
||||
download: () => Promise<void>
|
||||
quitAndInstall: () => Promise<void>
|
||||
onStatus: (callback: (status: UpdateStatus) => void) => () => void
|
||||
}
|
||||
|
||||
type UIApi = {
|
||||
get: () => Promise<PersistedUIState>
|
||||
set: (args: Partial<PersistedUIState>) => Promise<void>
|
||||
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<string>
|
||||
writeClipboardText: (text: string) => Promise<void>
|
||||
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<RuntimeStatus>
|
||||
getStatus: () => Promise<RuntimeStatus>
|
||||
}
|
||||
|
||||
type FsApi = {
|
||||
readDir: (args: { dirPath: string }) => Promise<DirEntry[]>
|
||||
readFile: (args: {
|
||||
filePath: string
|
||||
}) => Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }>
|
||||
writeFile: (args: { filePath: string; content: string }) => Promise<void>
|
||||
createFile: (args: { filePath: string }) => Promise<void>
|
||||
createDir: (args: { dirPath: string }) => Promise<void>
|
||||
rename: (args: { oldPath: string; newPath: string }) => Promise<void>
|
||||
deletePath: (args: { targetPath: string }) => Promise<void>
|
||||
authorizeExternalPath: (args: { targetPath: string }) => Promise<void>
|
||||
stat: (args: {
|
||||
filePath: string
|
||||
}) => Promise<{ size: number; isDirectory: boolean; mtime: number }>
|
||||
listFiles: (args: { rootPath: string }) => Promise<string[]>
|
||||
search: (args: SearchOptions) => Promise<SearchResult>
|
||||
}
|
||||
|
||||
type GitApi = {
|
||||
status: (args: { worktreePath: string }) => Promise<GitStatusResult>
|
||||
conflictOperation: (args: { worktreePath: string }) => Promise<GitConflictOperation>
|
||||
diff: (args: {
|
||||
worktreePath: string
|
||||
filePath: string
|
||||
staged: boolean
|
||||
}) => Promise<GitDiffResult>
|
||||
branchCompare: (args: {
|
||||
worktreePath: string
|
||||
baseRef: string
|
||||
}) => Promise<GitBranchCompareResult>
|
||||
branchDiff: (args: {
|
||||
worktreePath: string
|
||||
compare: {
|
||||
baseRef: string
|
||||
baseOid: string
|
||||
headOid: string
|
||||
mergeBase: string
|
||||
}
|
||||
filePath: string
|
||||
oldPath?: string
|
||||
}) => Promise<GitDiffResult>
|
||||
stage: (args: { worktreePath: string; filePath: string }) => Promise<void>
|
||||
bulkStage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
|
||||
unstage: (args: { worktreePath: string; filePath: string }) => Promise<void>
|
||||
bulkUnstage: (args: { worktreePath: string; filePaths: string[] }) => Promise<void>
|
||||
discard: (args: { worktreePath: string; filePath: string }) => Promise<void>
|
||||
remoteFileUrl: (args: {
|
||||
worktreePath: string
|
||||
relativePath: string
|
||||
line: number
|
||||
}) => Promise<string | null>
|
||||
}
|
||||
|
||||
type PreflightStatus = {
|
||||
git: { installed: boolean }
|
||||
gh: { installed: boolean; authenticated: boolean }
|
||||
}
|
||||
|
||||
type PreflightApi = {
|
||||
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
|
||||
}
|
||||
|
||||
type StatsApi = {
|
||||
getSummary: () => Promise<StatsSummary>
|
||||
}
|
||||
|
||||
type ClaudeUsageApi = {
|
||||
getScanState: () => Promise<ClaudeUsageScanState>
|
||||
setEnabled: (args: { enabled: boolean }) => Promise<ClaudeUsageScanState>
|
||||
refresh: (args?: { force?: boolean }) => Promise<ClaudeUsageScanState>
|
||||
getSummary: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
}) => Promise<ClaudeUsageSummary>
|
||||
getDaily: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
}) => Promise<ClaudeUsageDailyPoint[]>
|
||||
getBreakdown: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
kind: ClaudeUsageBreakdownKind
|
||||
}) => Promise<ClaudeUsageBreakdownRow[]>
|
||||
getRecentSessions: (args: {
|
||||
scope: ClaudeUsageScope
|
||||
range: ClaudeUsageRange
|
||||
limit?: number
|
||||
}) => Promise<ClaudeUsageSessionRow[]>
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -305,6 +305,34 @@ const api = {
|
||||
ipcRenderer.invoke('shell:copyFile', args)
|
||||
},
|
||||
|
||||
browser: {
|
||||
registerGuest: (args: { browserTabId: string; webContentsId: number }): Promise<void> =>
|
||||
ipcRenderer.invoke('browser:registerGuest', args),
|
||||
|
||||
unregisterGuest: (args: { browserTabId: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('browser:unregisterGuest', args),
|
||||
|
||||
openDevTools: (args: { browserTabId: string }): Promise<boolean> =>
|
||||
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)
|
||||
|
||||
@@ -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<string, string | null>,
|
||||
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'>
|
||||
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
|
||||
): {
|
||||
openFilesByWorktree: Record<string, PersistedOpenFile[]>
|
||||
activeFileIdByWorktree: Record<string, string | null>
|
||||
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'>
|
||||
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
|
||||
} {
|
||||
const editFiles = openFiles.filter((f) => f.mode === 'edit')
|
||||
const byWorktree: Record<string, PersistedOpenFile[]> = {}
|
||||
@@ -61,6 +61,27 @@ function buildEditorSessionData(
|
||||
}
|
||||
}
|
||||
|
||||
function buildBrowserSessionData(
|
||||
browserTabsByWorktree: Record<string, BrowserTab[]>,
|
||||
activeBrowserTabIdByWorktree: Record<string, string | null>
|
||||
): {
|
||||
browserTabsByWorktree: Record<string, BrowserTab[]>
|
||||
activeBrowserTabIdByWorktree: Record<string, string | null>
|
||||
} {
|
||||
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)
|
||||
|
||||
@@ -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<string | null>(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<typeof updateBrowserTabPageState>[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<Set<string>>(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 (
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${activeWorktreeId ? '' : ' hidden'}`}
|
||||
@@ -450,19 +684,24 @@ export default function Terminal(): React.JSX.Element | null {
|
||||
onCloseOthers={handleCloseOthers}
|
||||
onCloseToRight={handleCloseTabsToRight}
|
||||
onReorder={setTabBarOrder}
|
||||
onNewTab={handleNewTab}
|
||||
onNewTerminalTab={handleNewTab}
|
||||
onNewBrowserTab={handleNewBrowserTab}
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
onTogglePaneExpand={handleTogglePaneExpand}
|
||||
editorFiles={worktreeFiles}
|
||||
browserTabs={worktreeBrowserTabs}
|
||||
activeFileId={activeFileId}
|
||||
activeBrowserTabId={activeBrowserTabId}
|
||||
activeTabType={activeTabType}
|
||||
onActivateFile={(fileId) => {
|
||||
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 */}
|
||||
<div
|
||||
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType === 'editor' && worktreeFiles.length > 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 {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType !== 'browser' ? 'hidden' : ''}`}
|
||||
>
|
||||
{allWorktrees.map((worktree) => {
|
||||
const browserTabs = browserTabsByWorktree[worktree.id] ?? []
|
||||
const isVisibleWorktree = activeView !== 'settings' && worktree.id === activeWorktreeId
|
||||
if (browserTabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`browser-${worktree.id}`}
|
||||
className={isVisibleWorktree ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isVisibleWorktree}
|
||||
>
|
||||
{isVisibleWorktree && activeTabType === 'browser'
|
||||
? browserTabs
|
||||
.filter((browserTab) => browserTab.id === activeBrowserTabId)
|
||||
.map((browserTab) => (
|
||||
<BrowserPane
|
||||
key={browserTab.id}
|
||||
browserTab={browserTab}
|
||||
onUpdatePageState={handleBrowserTabPageStateUpdate}
|
||||
onSetUrl={handleBrowserTabSetUrl}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && (
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
SquareCode
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants'
|
||||
import type { BrowserLoadError, BrowserTab as BrowserTabState } from '../../../../shared/types'
|
||||
import {
|
||||
normalizeBrowserNavigationUrl,
|
||||
normalizeExternalBrowserUrl
|
||||
} from '../../../../shared/browser-url'
|
||||
import {
|
||||
clearLiveBrowserUrl,
|
||||
consumeEvictedBrowserTab,
|
||||
markEvictedBrowserTab,
|
||||
rememberLiveBrowserUrl
|
||||
} from './browser-runtime'
|
||||
|
||||
type BrowserTabPageState = Partial<
|
||||
Pick<
|
||||
BrowserTabState,
|
||||
'title' | 'loading' | 'faviconUrl' | 'canGoBack' | 'canGoForward' | 'loadError'
|
||||
>
|
||||
>
|
||||
|
||||
const webviewRegistry = new Map<string, Electron.WebviewTag>()
|
||||
const registeredWebContentsIds = new Map<string, number>()
|
||||
const parkedAtByTabId = new Map<string, number>()
|
||||
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<HTMLDivElement | null>(null)
|
||||
const webviewRef = useRef<Electron.WebviewTag | null>(null)
|
||||
const faviconUrlRef = useRef<string | null>(browserTab.faviconUrl)
|
||||
const initialBrowserUrlRef = useRef(browserTab.url)
|
||||
const browserTabUrlRef = useRef(browserTab.url)
|
||||
const activeLoadFailureRef = useRef<BrowserLoadError | null>(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<string | null>(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 (
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col">
|
||||
<div className="relative z-10 flex items-center gap-2 border-b border-border/70 bg-background/95 px-3 py-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => webviewRef.current?.goBack()}
|
||||
disabled={!browserTab.canGoBack}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => webviewRef.current?.goForward()}
|
||||
disabled={!browserTab.canGoForward}
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) {
|
||||
return
|
||||
}
|
||||
if (browserTab.loading) {
|
||||
webview.stop()
|
||||
} else if (browserTab.loadError) {
|
||||
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
|
||||
} else {
|
||||
webview.reload()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{browserTab.loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border border-border bg-background px-3 py-1.5 shadow-sm"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitAddressBar()
|
||||
}}
|
||||
>
|
||||
<Globe className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
value={addressBarValue}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => void window.api.browser.openDevTools({ browserTabId: browserTab.id })}
|
||||
title="Open browser devtools"
|
||||
>
|
||||
<SquareCode className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
if (!externalUrl) {
|
||||
return
|
||||
}
|
||||
void window.api.shell.openUrl(externalUrl)
|
||||
}}
|
||||
title="Open in default browser"
|
||||
disabled={!externalUrl}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{resourceNotice ? (
|
||||
<div className="border-b border-border/60 bg-background px-3 py-1.5 text-xs text-muted-foreground">
|
||||
{resourceNotice}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex min-h-0 flex-1 overflow-hidden bg-background"
|
||||
>
|
||||
{showFailureOverlay ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
|
||||
<div className="flex max-w-sm flex-col items-center px-8 py-8 text-center opacity-70">
|
||||
<div className="mb-4 rounded-full border border-border/70 bg-muted/30 p-3">
|
||||
<Globe className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-foreground/85">
|
||||
{loadErrorMeta.host ? `Can't reach ${loadErrorMeta.host}` : "Can't load this page"}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{getFriendlyLoadErrorDescription(browserTab.loadError)}
|
||||
</p>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 gap-2 px-3"
|
||||
title="Retry"
|
||||
onClick={() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) {
|
||||
return
|
||||
}
|
||||
onUpdatePageStateRef.current(browserTab.id, {
|
||||
loading: true
|
||||
})
|
||||
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
<span>Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{isBlankTab ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
|
||||
<div className="flex flex-col items-center px-8 py-8 text-center opacity-70">
|
||||
<div className="mb-4 rounded-full border border-border/70 bg-muted/30 p-3">
|
||||
<Globe className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-foreground/85">New Browser Tab</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Type a URL above to start browsing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const liveBrowserUrlByTabId = new Map<string, string>()
|
||||
const evictedBrowserTabIds = new Set<string>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -158,6 +158,39 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title="Open Links In Orca"
|
||||
description="Open terminal http(s) links in Orca browser tabs instead of the system browser."
|
||||
keywords={['browser', 'preview', 'links', 'localhost', 'webview']}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>Open Links In Orca</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Open terminal http(s) links in isolated Orca browser tabs instead of the system
|
||||
browser.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.openLinksInApp}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
openLinksInApp: !settings.openLinksInApp
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
settings.openLinksInApp ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, GENERAL_EDITOR_SEARCH_ENTRIES) ? (
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -58,6 +58,25 @@ const CONFLICT_OPERATION_LABELS: Record<Exclude<GitConflictOperation, 'unknown'>
|
||||
|
||||
// ── 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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, Repo>([
|
||||
[
|
||||
'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])
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,8 @@ export function computeVisibleWorktreeIds(
|
||||
searchQuery: string
|
||||
showActiveOnly: boolean
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null
|
||||
browserTabsByWorktree?: Record<string, { id: string }[]> | null
|
||||
activeWorktreeId?: string | null
|
||||
repoMap: Map<string, Repo>
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
onContextMenuCapture={(event) => {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
|
||||
setMenuPoint({ x: event.clientX, y: event.clientY })
|
||||
setMenuOpen(true)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
|
||||
isActive
|
||||
? 'bg-accent/40 text-foreground border-b-transparent'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Globe
|
||||
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="truncate max-w-[180px] mr-1.5">{getBrowserTabLabel(tab)}</span>
|
||||
{tab.loading && !tab.loadError && !isBlankBrowserTab(tab) && (
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-sky-500/80 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none fixed size-px opacity-0"
|
||||
style={{ left: menuPoint.x, top: menuPoint.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[11rem] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]"
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem onSelect={onClose}>Close</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onCloseToRight} disabled={!hasTabsToRight}>
|
||||
Close Tabs To The Right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void window.api.shell.openUrl(openInBrowserUrl)}
|
||||
disabled={!isHttpUrl}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
Open In Browser
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,14 +8,30 @@ import {
|
||||
type DragEndEvent
|
||||
} from '@dnd-kit/core'
|
||||
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { Globe, Plus, TerminalSquare } from 'lucide-react'
|
||||
import type {
|
||||
BrowserTab as BrowserTabState,
|
||||
TerminalTab,
|
||||
WorkspaceVisibleTabType
|
||||
} from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { buildStatusMap } from '../right-sidebar/status-display'
|
||||
import type { OpenFile } from '../../store/slices/editor'
|
||||
import SortableTab from './SortableTab'
|
||||
import EditorFileTab from './EditorFileTab'
|
||||
import BrowserTab from './BrowserTab'
|
||||
import { reconcileTabOrder } from './reconcile-order'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const NEW_TERMINAL_SHORTCUT = isMac ? '⌘T' : 'Ctrl+T'
|
||||
const NEW_BROWSER_SHORTCUT = isMac ? '⌘⇧B' : 'Ctrl+Shift+B'
|
||||
|
||||
type TabBarProps = {
|
||||
tabs: TerminalTab[]
|
||||
@@ -27,15 +43,20 @@ type TabBarProps = {
|
||||
onCloseOthers: (tabId: string) => void
|
||||
onCloseToRight: (tabId: string) => void
|
||||
onReorder: (worktreeId: string, order: 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
|
||||
editorFiles?: OpenFile[]
|
||||
browserTabs?: BrowserTabState[]
|
||||
activeFileId?: string | null
|
||||
activeTabType?: 'terminal' | 'editor'
|
||||
activeBrowserTabId?: string | null
|
||||
activeTabType?: WorkspaceVisibleTabType
|
||||
onActivateFile?: (fileId: string) => void
|
||||
onCloseFile?: (fileId: string) => void
|
||||
onActivateBrowserTab?: (tabId: string) => void
|
||||
onCloseBrowserTab?: (tabId: string) => void
|
||||
onCloseAllFiles?: () => void
|
||||
onPinFile?: (fileId: string) => void
|
||||
tabBarOrder?: string[]
|
||||
@@ -44,6 +65,7 @@ type TabBarProps = {
|
||||
type TabItem =
|
||||
| { type: 'terminal'; id: string; data: TerminalTab }
|
||||
| { type: 'editor'; id: string; data: OpenFile }
|
||||
| { type: 'browser'; id: string; data: BrowserTabState }
|
||||
|
||||
export default function TabBar({
|
||||
tabs,
|
||||
@@ -55,15 +77,20 @@ export default function TabBar({
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onReorder,
|
||||
onNewTab,
|
||||
onNewTerminalTab,
|
||||
onNewBrowserTab,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor,
|
||||
onTogglePaneExpand,
|
||||
editorFiles,
|
||||
browserTabs,
|
||||
activeFileId,
|
||||
activeBrowserTabId,
|
||||
activeTabType,
|
||||
onActivateFile,
|
||||
onCloseFile,
|
||||
onActivateBrowserTab,
|
||||
onCloseBrowserTab,
|
||||
onCloseAllFiles,
|
||||
onPinFile,
|
||||
tabBarOrder
|
||||
@@ -82,13 +109,18 @@ export default function TabBar({
|
||||
|
||||
const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs])
|
||||
const editorMap = useMemo(() => new Map((editorFiles ?? []).map((f) => [f.id, f])), [editorFiles])
|
||||
const browserMap = useMemo(
|
||||
() => new Map((browserTabs ?? []).map((t) => [t.id, t])),
|
||||
[browserTabs]
|
||||
)
|
||||
|
||||
const terminalIds = useMemo(() => tabs.map((t) => t.id), [tabs])
|
||||
const editorFileIds = useMemo(() => editorFiles?.map((f) => f.id) ?? [], [editorFiles])
|
||||
const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs])
|
||||
|
||||
// Build the unified ordered list, reconciling stored order with current items
|
||||
const orderedItems = useMemo(() => {
|
||||
const ids = reconcileTabOrder(tabBarOrder, terminalIds, editorFileIds)
|
||||
const ids = reconcileTabOrder(tabBarOrder, terminalIds, editorFileIds, browserTabIds)
|
||||
const items: TabItem[] = []
|
||||
for (const id of ids) {
|
||||
const terminal = terminalMap.get(id)
|
||||
@@ -99,10 +131,15 @@ export default function TabBar({
|
||||
const file = editorMap.get(id)
|
||||
if (file) {
|
||||
items.push({ type: 'editor', id, data: file })
|
||||
continue
|
||||
}
|
||||
const browserTab = browserMap.get(id)
|
||||
if (browserTab) {
|
||||
items.push({ type: 'browser', id, data: browserTab })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [tabBarOrder, terminalIds, editorFileIds, terminalMap, editorMap])
|
||||
}, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap])
|
||||
|
||||
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
|
||||
|
||||
@@ -182,6 +219,19 @@ export default function TabBar({
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'browser') {
|
||||
return (
|
||||
<BrowserTab
|
||||
key={item.id}
|
||||
tab={item.data}
|
||||
isActive={activeTabType === 'browser' && activeBrowserTabId === item.id}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
onActivate={() => onActivateBrowserTab?.(item.id)}
|
||||
onClose={() => onCloseBrowserTab?.(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<EditorFileTab
|
||||
key={item.id}
|
||||
@@ -200,14 +250,39 @@ export default function TabBar({
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button
|
||||
className="flex items-center justify-center w-7 h-7 my-auto mx-1 shrink-0 rounded text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
onClick={onNewTab}
|
||||
title="New terminal (Cmd+T)"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="mx-1 my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
title="New tab"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="min-w-[11rem] rounded-[11px] border-border/80 p-1 shadow-[0_16px_36px_rgba(0,0,0,0.24)]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={onNewTerminalTab}
|
||||
className="gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium"
|
||||
>
|
||||
<TerminalSquare className="size-4 text-muted-foreground" />
|
||||
New Terminal
|
||||
<DropdownMenuShortcut>{NEW_TERMINAL_SHORTCUT}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={onNewBrowserTab}
|
||||
className="gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium"
|
||||
>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
New Browser Tab
|
||||
<DropdownMenuShortcut>{NEW_BROWSER_SHORTCUT}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
+21
@@ -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<MermaidRenderResult>
|
||||
}
|
||||
|
||||
const mermaid: MermaidApi
|
||||
export default mermaid
|
||||
}
|
||||
@@ -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<AppState>()((...a) => ({
|
||||
...createRepoSlice(...a),
|
||||
@@ -21,7 +22,8 @@ export const useAppStore = create<AppState>()((...a) => ({
|
||||
...createGitHubSlice(...a),
|
||||
...createEditorSlice(...a),
|
||||
...createStatsSlice(...a),
|
||||
...createClaudeUsageSlice(...a)
|
||||
...createClaudeUsageSlice(...a),
|
||||
...createBrowserSlice(...a)
|
||||
}))
|
||||
|
||||
export type { AppState } from './types'
|
||||
|
||||
@@ -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<string, BrowserTab[]>
|
||||
activeBrowserTabId: string | null
|
||||
activeBrowserTabIdByWorktree: Record<string, string | null>
|
||||
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<AppState, [], [], BrowserSlice> = (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<string, BrowserTab[]> = {}
|
||||
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<string, BrowserTab[]> = 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<string, string | null> = {}
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,9 @@ function createEditorStore(): StoreApi<AppState> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((...args: any[]) => ({
|
||||
activeWorktreeId: 'wt-1',
|
||||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
|
||||
})) as unknown as StoreApi<AppState>
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, string | null> // worktreeId -> last active file
|
||||
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'> // worktreeId -> last active tab type
|
||||
activeTabType: 'terminal' | 'editor'
|
||||
setActiveTabType: (type: 'terminal' | 'editor') => void
|
||||
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType> // worktreeId -> last active tab type
|
||||
activeTabType: WorkspaceVisibleTabType
|
||||
setActiveTabType: (type: WorkspaceVisibleTabType) => void
|
||||
openFile: (file: Omit<OpenFile, 'id' | 'isDirty'>, options?: { preview?: boolean }) => void
|
||||
pinFile: (fileId: string) => void
|
||||
closeFile: (fileId: string) => void
|
||||
@@ -526,21 +527,42 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<AppState>()((...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<BrowserTab> & { 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', () => {
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AppState, [], [], WorktreeSlice>
|
||||
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<AppState, [], [], WorktreeSlice>
|
||||
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<AppState, [], [], WorktreeSlice>
|
||||
|
||||
// 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<AppState, [], [], WorktreeSlice>
|
||||
return {
|
||||
activeWorktreeId: worktreeId,
|
||||
activeFileId,
|
||||
activeBrowserTabId,
|
||||
activeTabType,
|
||||
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: activeTabType },
|
||||
activeTabId,
|
||||
worktreesByRepo: applyWorktreeUpdates(
|
||||
s.worktreesByRepo,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: {}
|
||||
}
|
||||
|
||||
+32
-3
@@ -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<string, PersistedOpenFile[]>
|
||||
/** Per-worktree active editor file ID (filePath) at shutdown. */
|
||||
activeFileIdByWorktree?: Record<string, string | null>
|
||||
/** Per-worktree active tab type (terminal vs editor) at shutdown. */
|
||||
activeTabTypeByWorktree?: Record<string, 'terminal' | 'editor'>
|
||||
/** Persisted browser tabs, keyed by worktree ID. */
|
||||
browserTabsByWorktree?: Record<string, BrowserTab[]>
|
||||
/** Per-worktree active browser tab ID at shutdown. */
|
||||
activeBrowserTabIdByWorktree?: Record<string, string | null>
|
||||
/** Per-worktree active tab type (terminal vs editor vs browser) at shutdown. */
|
||||
activeTabTypeByWorktree?: Record<string, WorkspaceVisibleTabType>
|
||||
/** Per-worktree last-active terminal tab ID at shutdown. */
|
||||
activeTabIdByWorktree?: Record<string, string | null>
|
||||
/** 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
|
||||
|
||||
Reference in New Issue
Block a user