mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
* 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.
396 lines
14 KiB
TypeScript
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())
|
|
}, [])
|
|
}
|