orca/src/renderer/src/hooks/useIpcEvents.ts
Neil d2a53c8fcc
New workspace create page (#710)
* New workspace page with agent catalog, composer modal, and terminal integration

* fix lint

* better blank state

* fix: resolve typecheck errors in new-workspace flow

- widen activateAndRevealWorktree's issueCommand to accept direct
  command shape used by NewWorkspacePage, not just the main-process
  runner-script variant
- use a ref object in pty-connection tests to dodge TS narrowing the
  captured callback to never across the mock closure boundary

* fix: bound worktree name auto-suffix loop to prevent OOM in tests

The auto-suffix loop in createLocalWorktree had no termination cap.
When tests (or a misconfigured env) mocked getBranchConflictKind /
getPRForBranch to always return a collision, the loop ran forever and
the vitest worker crashed with "Ineffective mark-compacts near heap
limit".

Cap the search at 100 suffixes, and if all fail, fall back to the
original specific error messages (branch already exists / PR already
owns the name) instead of a generic failure.

Update the two tests that asserted the old throw-on-first-conflict
behavior to verify the new auto-suffix path end-to-end.
2026-04-16 15:41:40 -07:00

396 lines
14 KiB
TypeScript

/* oxlint-disable max-lines */
import { useEffect } from 'react'
import { useAppStore } from '../store'
import { applyUIZoom } from '@/lib/ui-zoom'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal
} from '@/lib/worktree-activation'
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom'
import type { UpdateStatus } from '../../../shared/types'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { SshConnectionState } from '../../../shared/ssh-types'
import { zoomLevelToPercent, ZOOM_MIN, ZOOM_MAX } from '@/components/settings/SettingsConstants'
import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
import { resolveZoomTarget } from './resolve-zoom-target'
import { handleSwitchTab } from './ipc-tab-switch'
export { resolveZoomTarget } from './resolve-zoom-target'
const ZOOM_STEP = 0.5
export function useIpcEvents(): void {
useEffect(() => {
const unsubs: (() => void)[] = []
unsubs.push(
window.api.repos.onChanged(() => {
useAppStore.getState().fetchRepos()
})
)
unsubs.push(
window.api.worktrees.onChanged((data: { repoId: string }) => {
useAppStore.getState().fetchWorktrees(data.repoId)
})
)
unsubs.push(
window.api.ui.onOpenSettings(() => {
useAppStore.getState().openSettingsPage()
})
)
unsubs.push(
window.api.ui.onToggleLeftSidebar(() => {
useAppStore.getState().toggleSidebar()
})
)
unsubs.push(
window.api.ui.onToggleRightSidebar(() => {
useAppStore.getState().toggleRightSidebar()
})
)
unsubs.push(
window.api.ui.onToggleWorktreePalette(() => {
const store = useAppStore.getState()
if (store.activeModal === 'worktree-palette') {
store.closeModal()
return
}
store.openModal('worktree-palette')
})
)
unsubs.push(
window.api.ui.onOpenQuickOpen(() => {
const store = useAppStore.getState()
if (store.activeView === 'terminal' && store.activeWorktreeId !== null) {
store.openModal('quick-open')
}
})
)
unsubs.push(
window.api.ui.onJumpToWorktreeIndex((index) => {
const store = useAppStore.getState()
if (store.activeView !== 'terminal') {
return
}
const visibleIds = getVisibleWorktreeIds()
if (index < visibleIds.length) {
activateAndRevealWorktree(visibleIds[index])
}
})
)
unsubs.push(
window.api.ui.onToggleStatusBar(() => {
const store = useAppStore.getState()
store.setStatusBarVisible(!store.statusBarVisible)
})
)
unsubs.push(
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup }) => {
void (async () => {
const store = useAppStore.getState()
await store.fetchWorktrees(repoId)
// Why: CLI-created worktrees should feel identical to UI-created
// worktrees. The renderer owns the "active worktree -> first tab"
// behavior today, so we explicitly replay that activation sequence
// after the runtime creates a worktree outside the renderer.
store.setActiveRepo(repoId)
store.setActiveView('terminal')
store.setActiveWorktree(worktreeId)
ensureWorktreeHasInitialTerminal(store, worktreeId, setup)
store.revealWorktreeInSidebar(worktreeId)
})().catch((error) => {
console.error('Failed to activate CLI-created worktree:', error)
})
})
)
// Hydrate initial update status then subscribe to changes
window.api.updater.getStatus().then((status) => {
useAppStore.getState().setUpdateStatus(status as UpdateStatus)
})
unsubs.push(
window.api.updater.onStatus((raw) => {
const status = raw as UpdateStatus
useAppStore.getState().setUpdateStatus(status)
})
)
unsubs.push(
window.api.updater.onClearDismissal(() => {
useAppStore.getState().clearDismissedUpdateVersion()
})
)
unsubs.push(
window.api.ui.onFullscreenChanged((isFullScreen) => {
useAppStore.getState().setIsFullScreen(isFullScreen)
})
)
unsubs.push(
window.api.browser.onGuestLoadFailed(({ browserPageId, loadError }) => {
useAppStore.getState().updateBrowserPageState(browserPageId, {
loading: false,
loadError,
canGoBack: false,
canGoForward: false
})
})
)
unsubs.push(
window.api.browser.onOpenLinkInOrcaTab(({ browserPageId, url }) => {
const store = useAppStore.getState()
const sourcePage = Object.values(store.browserPagesByWorkspace)
.flat()
.find((page) => page.id === browserPageId)
if (!sourcePage) {
return
}
// Why: the guest process can request "open this link in Orca", but it
// does not own Orca's worktree/tab model. Resolve the source page's
// worktree and create a new outer browser tab so the link opens as a
// separate tab in the outer Orca tab bar.
store.createBrowserTab(sourcePage.worktreeId, url, { title: url })
})
)
// Shortcut forwarding for embedded browser guests whose webContents
// capture keyboard focus and bypass the renderer's window-level keydown.
unsubs.push(
window.api.ui.onNewBrowserTab(() => {
const store = useAppStore.getState()
const worktreeId = store.activeWorktreeId
if (worktreeId) {
store.createBrowserTab(worktreeId, store.browserDefaultUrl ?? 'about:blank', {
title: 'New Browser Tab'
})
}
})
)
unsubs.push(
window.api.ui.onNewTerminalTab(() => {
const store = useAppStore.getState()
const worktreeId = store.activeWorktreeId
if (!worktreeId) {
return
}
const newTab = store.createTab(worktreeId)
store.setActiveTabType('terminal')
// Why: replicate the full reconciliation from Terminal.tsx handleNewTab
// so the new tab appends at the visual end instead of jumping to index 0
// when tabBarOrderByWorktree is unset (e.g. restored worktrees).
const currentTerminals = store.tabsByWorktree[worktreeId] ?? []
const currentEditors = store.openFiles.filter((f) => f.worktreeId === worktreeId)
const currentBrowsers = store.browserTabsByWorktree[worktreeId] ?? []
const stored = store.tabBarOrderByWorktree[worktreeId]
const termIds = currentTerminals.map((t) => t.id)
const editorIds = currentEditors.map((f) => f.id)
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, ...browserIds]) {
if (!inBase.has(id)) {
base.push(id)
inBase.add(id)
}
}
const order = base.filter((id) => id !== newTab.id)
order.push(newTab.id)
store.setTabBarOrder(worktreeId, order)
})
)
unsubs.push(
window.api.ui.onCloseActiveTab(() => {
const store = useAppStore.getState()
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
store.closeBrowserTab(store.activeBrowserTabId)
}
})
)
unsubs.push(window.api.ui.onSwitchTab(handleSwitchTab))
// Hydrate initial rate limit state then subscribe to push updates
window.api.rateLimits.get().then((state) => {
useAppStore.getState().setRateLimitsFromPush(state as RateLimitState)
})
unsubs.push(
window.api.rateLimits.onUpdate((state) => {
useAppStore.getState().setRateLimitsFromPush(state as RateLimitState)
})
)
// Track SSH connection state changes so the renderer can show
// disconnected indicators on remote worktrees.
// Why: hydrate initial state for all known targets so worktree cards
// reflect the correct connected/disconnected state on app launch.
void (async () => {
try {
const targets = (await window.api.ssh.listTargets()) as {
id: string
label: string
}[]
// Why: populate target labels map so WorktreeCard (and other components)
// can look up display labels without issuing per-card IPC calls.
const labels = new Map<string, string>()
for (const target of targets) {
labels.set(target.id, target.label)
const state = await window.api.ssh.getState({ targetId: target.id })
if (state) {
useAppStore.getState().setSshConnectionState(target.id, state as SshConnectionState)
}
}
useAppStore.getState().setSshTargetLabels(labels)
} catch {
// SSH may not be configured
}
})()
unsubs.push(
window.api.ssh.onCredentialRequest((data) => {
useAppStore.getState().enqueueSshCredentialRequest(data)
})
)
unsubs.push(
window.api.ssh.onCredentialResolved(({ requestId }) => {
useAppStore.getState().removeSshCredentialRequest(requestId)
})
)
unsubs.push(
window.api.ssh.onStateChanged((data: { targetId: string; state: unknown }) => {
const store = useAppStore.getState()
const state = data.state as SshConnectionState
store.setSshConnectionState(data.targetId, state)
const remoteRepos = store.repos.filter((r) => r.connectionId === data.targetId)
// Why: targets added after boot aren't in the labels map. Re-fetch
// so the status bar popover shows the new target immediately.
if (!store.sshTargetLabels.has(data.targetId)) {
window.api.ssh
.listTargets()
.then((targets) => {
const labels = new Map<string, string>()
for (const t of targets as { id: string; label: string }[]) {
labels.set(t.id, t.label)
}
useAppStore.getState().setSshTargetLabels(labels)
})
.catch(() => {})
}
if (
['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)
) {
// Why: an explicit disconnect or terminal failure tears down the SSH
// PTY provider without emitting per-PTY exit events. Clear the stale
// PTY ids in renderer state so a later reconnect remounts TerminalPane
// instead of keeping a dead remote PTY attached to the tab.
const remoteWorktreeIds = new Set(
Object.values(store.worktreesByRepo)
.flat()
.filter((w) => remoteRepos.some((r) => r.id === w.repoId))
.map((w) => w.id)
)
for (const worktreeId of remoteWorktreeIds) {
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
for (const tab of tabs) {
if (tab.ptyId) {
useAppStore.getState().clearTabPtyId(tab.id)
}
}
}
}
if (state.status === 'connected') {
void Promise.all(remoteRepos.map((r) => store.fetchWorktrees(r.id))).then(() => {
// Why: terminal panes that failed to spawn (no PTY provider on cold
// start) sit inert. Bumping generation forces TerminalPane to remount
// and retry pty:spawn. Only bump tabs with no live ptyId.
const freshStore = useAppStore.getState()
const remoteRepoIds = new Set(remoteRepos.map((r) => r.id))
const worktreeIds = Object.values(freshStore.worktreesByRepo)
.flat()
.filter((w) => remoteRepoIds.has(w.repoId))
.map((w) => w.id)
for (const worktreeId of worktreeIds) {
const tabs = freshStore.tabsByWorktree[worktreeId] ?? []
const hasDead = tabs.some((t) => !t.ptyId)
if (hasDead) {
useAppStore.setState((s) => ({
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((t) =>
t.ptyId ? t : { ...t, generation: (t.generation ?? 0) + 1 }
)
}
}))
}
}
})
}
})
)
// Zoom handling for menu accelerators and keyboard fallback paths.
unsubs.push(
window.api.ui.onTerminalZoom((direction) => {
const { activeView, activeTabType, editorFontZoomLevel, setEditorFontZoomLevel, settings } =
useAppStore.getState()
const target = resolveZoomTarget({
activeView,
activeTabType,
activeElement: document.activeElement
})
if (target === 'terminal') {
return
}
if (target === 'editor') {
const next = nextEditorFontZoomLevel(editorFontZoomLevel, direction)
setEditorFontZoomLevel(next)
void window.api.ui.set({ editorFontZoomLevel: next })
// Why: use the same base font size the editor surfaces use (terminalFontSize)
// and computeEditorFontSize to account for clamping, so the overlay percent
// matches the actual rendered size.
const baseFontSize = settings?.terminalFontSize ?? 13
const actual = computeEditorFontSize(baseFontSize, next)
const percent = Math.round((actual / baseFontSize) * 100)
dispatchZoomLevelChanged('editor', percent)
return
}
const current = window.api.ui.getZoomLevel()
const rawNext =
direction === 'in' ? current + ZOOM_STEP : direction === 'out' ? current - ZOOM_STEP : 0
const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, rawNext))
applyUIZoom(next)
void window.api.ui.set({ uiZoomLevel: next })
dispatchZoomLevelChanged('ui', zoomLevelToPercent(next))
})
)
return () => unsubs.forEach((fn) => fn())
}, [])
}