Extract sub-components and hooks from renderer components

To improve component focus and maintainability, extract large inline
sub-components, custom hooks, and logic helpers into dedicated files:

- Extract `HeroPaired` from `MobileHero` to `MobileHeroPairedDevices`.
- Move `ChromePreview` from `ThemeStep` to `theme-chrome-preview`.
- Refactor `HostedReviewActions` to use `useHostedReviewActions` hook.
- Move MCP config loading from `McpConfigSection` to helper file.
This commit is contained in:
Jinjing
2026-06-11 10:53:42 -07:00
parent df37730de0
commit 1735fda8ad
8 changed files with 507 additions and 424 deletions
@@ -1,19 +1,14 @@
import { ArrowLeft, ArrowRight, Copy, RefreshCw, Smartphone, Trash2 } from 'lucide-react'
import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { translate } from '@/i18n/i18n'
export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices'
export type Platform = 'ios' | 'android'
export type StepIndex = 0 | 1
export type PairedDevice = {
deviceId: string
name: string
pairedAt: number
lastSeenAt: number
}
// Why: header copy needs to refer to the *user's* device by its native name.
function getDeviceLabel(): string {
const ua = navigator.userAgent
@@ -79,81 +74,6 @@ export function HeroIntro({ onStart }: { onStart: () => void }): React.JSX.Eleme
)
}
type HeroPairedProps = {
devices: readonly PairedDevice[]
onPairAnother: () => void
onRevoke: (deviceId: string) => void
revokingDeviceIds: readonly string[]
}
export function HeroPaired({
devices,
onPairAnother,
onRevoke,
revokingDeviceIds
}: HeroPairedProps): React.JSX.Element {
return (
<div>
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
</span>
</div>
<h1 className="mp-h1">
{devices.length === 1
? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
: translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
</h1>
<p className="mp-lead-sm">
{translate(
'auto.components.mobile.MobileHero.266c18c105',
'Open Orca Mobile to pick up where you left off, or pair another device.'
)}
</p>
<ul className="mp-paired-list">
{devices.map((device) => {
const revoking = revokingDeviceIds.includes(device.deviceId)
return (
<li key={device.deviceId} className="mp-paired-row">
<div className="mp-paired-icon">
<Smartphone className="size-4" />
</div>
<div className="mp-paired-main">
<div className="mp-paired-name">{device.name}</div>
<div className="mp-paired-meta">
{translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
{new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<button
type="button"
className="mp-paired-revoke"
onClick={() => onRevoke(device.deviceId)}
disabled={revoking}
aria-label={translate(
'auto.components.mobile.MobileHero.34f878d04f',
'Revoke {{value0}}',
{ value0: device.name }
)}
title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')}
>
<Trash2 className="size-3.5" />
</button>
</li>
)
})}
</ul>
<div className="mp-flow-actions">
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
<Smartphone className="size-3.5" />
{translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')}
</button>
<span />
</div>
</div>
)
}
type HeroFlowProps = {
stepIdx: StepIndex
platform: Platform
@@ -0,0 +1,84 @@
import { Smartphone, Trash2 } from 'lucide-react'
import { translate } from '@/i18n/i18n'
export type PairedDevice = {
deviceId: string
name: string
pairedAt: number
lastSeenAt: number
}
type HeroPairedProps = {
devices: readonly PairedDevice[]
onPairAnother: () => void
onRevoke: (deviceId: string) => void
revokingDeviceIds: readonly string[]
}
export function HeroPaired({
devices,
onPairAnother,
onRevoke,
revokingDeviceIds
}: HeroPairedProps): React.JSX.Element {
return (
<div>
<div className="mp-eyebrow-row">
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
</span>
</div>
<h1 className="mp-h1">
{devices.length === 1
? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
: translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
</h1>
<p className="mp-lead-sm">
{translate(
'auto.components.mobile.MobileHero.266c18c105',
'Open Orca Mobile to pick up where you left off, or pair another device.'
)}
</p>
<ul className="mp-paired-list">
{devices.map((device) => {
const revoking = revokingDeviceIds.includes(device.deviceId)
return (
<li key={device.deviceId} className="mp-paired-row">
<div className="mp-paired-icon">
<Smartphone className="size-4" />
</div>
<div className="mp-paired-main">
<div className="mp-paired-name">{device.name}</div>
<div className="mp-paired-meta">
{translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
{new Date(device.pairedAt).toLocaleDateString()}
</div>
</div>
<button
type="button"
className="mp-paired-revoke"
onClick={() => onRevoke(device.deviceId)}
disabled={revoking}
aria-label={translate(
'auto.components.mobile.MobileHero.34f878d04f',
'Revoke {{value0}}',
{ value0: device.name }
)}
title={translate('auto.components.mobile.MobileHero.f9cbf4bb53', 'Revoke device')}
>
<Trash2 className="size-3.5" />
</button>
</li>
)
})}
</ul>
<div className="mp-flow-actions">
<button type="button" className="mp-secondary-action" onClick={onPairAnother}>
<Smartphone className="size-3.5" />
{translate('auto.components.mobile.MobileHero.ff48d9d520', 'Pair another device')}
</button>
<span />
</div>
</div>
)
}
@@ -11,6 +11,7 @@ import type {
} from '../../../../shared/types'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { translate } from '@/i18n/i18n'
import { ChromePreview } from './theme-chrome-preview'
type ThemeStepProps = {
theme: GlobalSettings['theme']
@@ -361,78 +362,6 @@ function GhosttyDiscoveryRow({
)
}
function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
if (variant === 'system') {
return (
<div className="relative size-full">
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}
>
<ChromeMock dark />
</div>
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
>
<ChromeMock dark={false} />
</div>
<div
aria-hidden
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
/>
</div>
)
}
return <ChromeMock dark={variant === 'dark'} />
}
function ChromeMock({ dark }: { dark: boolean }) {
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
const row = dark ? 'bg-white/10' : 'bg-black/10'
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
const accent = 'bg-violet-500/80'
return (
<div className={cn('flex size-full', bg)}>
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
<div className="mt-0.5 flex items-center gap-1">
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-1 flex-1 rounded-sm', row)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
</div>
</div>
<div className="flex flex-1 flex-col p-1.5">
<div className="flex gap-1">
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
</div>
<div className="mt-1.5 flex-1 space-y-1">
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
</div>
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
</div>
</div>
</div>
)
}
function humanFields(diff: Partial<GlobalSettings>): string[] {
// Why: chip labels are a friendly summary, not a strict 1:1 of mapper keys.
// Group related diff keys (font weight + family + size → "Font") so the row
@@ -0,0 +1,74 @@
import { cn } from '@/lib/utils'
import type { GlobalSettings } from '../../../../shared/types'
export function ChromePreview({ variant }: { variant: GlobalSettings['theme'] }) {
if (variant === 'system') {
return (
<div className="relative size-full">
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(0 0, 50% 0, 50% 100%, 0 100%)' }}
>
<ChromeMock dark />
</div>
<div
className="absolute inset-0"
style={{ clipPath: 'polygon(50% 0, 100% 0, 100% 100%, 50% 100%)' }}
>
<ChromeMock dark={false} />
</div>
<div
aria-hidden
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
/>
</div>
)
}
return <ChromeMock dark={variant === 'dark'} />
}
function ChromeMock({ dark }: { dark: boolean }) {
// Tiny Orca chrome: sidebar with two rows + a content area with a tab and
// a composer line. Pure Tailwind so it stays lightweight inside the tile.
const bg = dark ? 'bg-[#0f1115]' : 'bg-[#f7f8fa]'
const sidebar = dark ? 'bg-[#16181d]' : 'bg-[#eceef2]'
const sidebarBorder = dark ? 'border-white/5' : 'border-black/5'
const row = dark ? 'bg-white/10' : 'bg-black/10'
const rowDim = dark ? 'bg-white/5' : 'bg-black/5'
const tab = dark ? 'bg-[#1d2026] border-white/5' : 'bg-white border-black/5'
const accent = 'bg-violet-500/80'
return (
<div className={cn('flex size-full', bg)}>
<div className={cn('flex w-[34%] flex-col gap-1 border-r p-1.5', sidebar, sidebarBorder)}>
<div className={cn('h-1 w-7 rounded-sm', rowDim)} />
<div className="mt-0.5 flex items-center gap-1">
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-1 flex-1 rounded-sm', row)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 flex-1 rounded-sm', rowDim)} />
</div>
<div className="flex items-center gap-1">
<span className={cn('size-1 rounded-full', rowDim)} />
<span className={cn('h-1 w-3/4 rounded-sm', rowDim)} />
</div>
</div>
<div className="flex flex-1 flex-col p-1.5">
<div className="flex gap-1">
<div className={cn('h-2 w-8 rounded-sm border', tab)} />
<div className={cn('h-2 w-5 rounded-sm', rowDim)} />
</div>
<div className="mt-1.5 flex-1 space-y-1">
<div className={cn('h-1 w-full rounded-sm', rowDim)} />
<div className={cn('h-1 w-5/6 rounded-sm', rowDim)} />
<div className={cn('h-1 w-2/3 rounded-sm', rowDim)} />
</div>
<div className={cn('mt-1 flex h-2.5 items-center gap-1 rounded-sm border px-1', tab)}>
<span className={cn('size-1 rounded-full', accent)} />
<span className={cn('h-0.5 flex-1 rounded-sm', rowDim)} />
</div>
</div>
</div>
)
}
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react'
import React, { useCallback, useMemo } from 'react'
import {
LoaderCircle,
GitMerge,
@@ -7,7 +7,6 @@ import {
GitPullRequestClosed,
CircleDot
} from 'lucide-react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -19,27 +18,14 @@ import {
DropdownMenuItem,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import type { GitHubPRMergeMethod } from '../../../../shared/types'
import { resolveGitHubPRMergeMethods } from '../../../../shared/github-pr-merge-methods'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
import { presentGitLabMRMergeState } from './gitlab-mr-merge-state'
import { useHostedReviewActions, type HostedReviewActionInfo } from './use-hosted-review-actions'
import { translate } from '@/i18n/i18n'
type HostedReviewActionInfo = Pick<
HostedReviewInfo,
'provider' | 'number' | 'state' | 'status' | 'mergeable'
> &
Partial<
Pick<
HostedReviewInfo,
'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus'
>
>
export default function HostedReviewActions({
review,
githubPR,
@@ -56,11 +42,6 @@ export default function HostedReviewActions({
const isDeletingWorktree = useAppStore(
(s) => s.deleteStateByWorktreeId[worktree.id]?.isDeleting ?? false
)
const confirm = useConfirmationDialog()
const [merging, setMerging] = useState(false)
const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const isGitLab = review.provider === 'gitlab'
const shortLabel = isGitLab ? 'MR' : 'PR'
const reviewLabel = isGitLab ? 'merge request' : 'pull request'
@@ -83,6 +64,25 @@ export default function HostedReviewActions({
() => resolveGitHubPRMergeMethods(isGitLab ? null : (githubPR?.mergeMethodSettings ?? null)),
[githubPR?.mergeMethodSettings, isGitLab]
)
const {
merging,
stateUpdating,
actionError,
handleMerge,
handleAutoMerge,
handleCloseReview,
handleReopenReview
} = useHostedReviewActions({
review,
githubPR,
repo,
isGitLab,
shortLabel,
reviewLabel,
defaultMergeMethod: mergeMethods.defaultMethod,
autoMergeAction: mergePresentation.autoMergeAction,
onRefreshReview
})
const isUpdatingReviewState = stateUpdating !== null
const primaryMergeDisabled =
merging ||
@@ -92,169 +92,6 @@ export default function HostedReviewActions({
merging || isUpdatingReviewState || !mergePresentation.directMergeAvailable
const menuDisabled = merging || isUpdatingReviewState
const handleMerge = useCallback(
async (method: GitHubPRMergeMethod = mergeMethods.defaultMethod) => {
setMerging(true)
setActionError(null)
try {
const result = isGitLab
? await window.api.gl.mergeMR({
repoPath: repo.path,
iid: review.number,
method
})
: await window.api.gh.mergePR({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
method,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Merge failed')
} finally {
setMerging(false)
}
},
[
githubPR?.prRepo,
isGitLab,
mergeMethods.defaultMethod,
onRefreshReview,
repo.id,
repo.path,
review.number
]
)
const handleAutoMerge = useCallback(async () => {
if (isGitLab || !mergePresentation.autoMergeAction) {
return
}
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
setMerging(true)
setActionError(null)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
enabled,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Auto-merge update failed')
} finally {
setMerging(false)
}
}, [
githubPR?.prRepo,
isGitLab,
mergePresentation.autoMergeAction,
onRefreshReview,
repo.id,
repo.path,
review.number
])
const handleReviewStateChange = useCallback(
async (nextState: 'open' | 'closed') => {
if (stateUpdating) {
return
}
const isClosing = nextState === 'closed'
const label = isClosing ? 'Close' : 'Reopen'
const confirmed = await confirm({
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
description: isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
'This will close the {{value0}}.',
{ value0: reviewLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
'This will reopen the {{value0}}.',
{ value0: reviewLabel }
),
confirmLabel: label,
confirmVariant: isClosing ? 'destructive' : 'default'
})
if (!confirmed) {
return
}
setStateUpdating(nextState)
setActionError(null)
try {
const result = isGitLab
? isClosing
? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number })
: await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number })
: await window.api.gh.updatePRState({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
updates: { state: nextState }
})
if (!result.ok) {
setActionError(result.error)
toast.error(result.error)
} else {
toast.success(
isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
'{{value0}} closed',
{ value0: shortLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
'{{value0}} reopened',
{ value0: shortLabel }
)
)
await onRefreshReview()
}
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}`
setActionError(message)
toast.error(message)
} finally {
setStateUpdating(null)
}
},
[
confirm,
isGitLab,
onRefreshReview,
repo.id,
repo.path,
review.number,
reviewLabel,
shortLabel,
stateUpdating
]
)
const handleCloseReview = useCallback(async () => {
await handleReviewStateChange('closed')
}, [handleReviewStateChange])
const handleReopenReview = useCallback(async () => {
await handleReviewStateChange('open')
}, [handleReviewStateChange])
const handleDeleteWorktree = useCallback(() => {
// Why: route every UI delete entry point through the shared funnel so
// skip-confirm, main-worktree, and child-workspace safeguards cannot drift.
@@ -0,0 +1,227 @@
import { useCallback, useState } from 'react'
import { toast } from 'sonner'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { PRInfo, Repo } from '../../../../shared/types'
import type { GitHubPRMergeMethod } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type HostedReviewActionInfo = Pick<
HostedReviewInfo,
'provider' | 'number' | 'state' | 'status' | 'mergeable'
> &
Partial<
Pick<
HostedReviewInfo,
'reviewDecision' | 'autoMergeEnabled' | 'mergeQueueRequired' | 'mergeStateStatus'
>
>
export function useHostedReviewActions({
review,
githubPR,
repo,
isGitLab,
shortLabel,
reviewLabel,
defaultMergeMethod,
autoMergeAction,
onRefreshReview
}: {
review: HostedReviewActionInfo
githubPR?: PRInfo | null
repo: Repo
isGitLab: boolean
shortLabel: string
reviewLabel: string
defaultMergeMethod: GitHubPRMergeMethod
autoMergeAction: GitHubPRAutoMergeAction | null
onRefreshReview: () => Promise<void>
}): {
merging: boolean
stateUpdating: 'open' | 'closed' | null
actionError: string | null
handleMerge: (method?: GitHubPRMergeMethod) => Promise<void>
handleAutoMerge: () => Promise<void>
handleCloseReview: () => Promise<void>
handleReopenReview: () => Promise<void>
} {
const confirm = useConfirmationDialog()
const [merging, setMerging] = useState(false)
const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const handleMerge = useCallback(
async (method: GitHubPRMergeMethod = defaultMergeMethod) => {
setMerging(true)
setActionError(null)
try {
const result = isGitLab
? await window.api.gl.mergeMR({
repoPath: repo.path,
iid: review.number,
method
})
: await window.api.gh.mergePR({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
method,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Merge failed')
} finally {
setMerging(false)
}
},
[
githubPR?.prRepo,
isGitLab,
defaultMergeMethod,
onRefreshReview,
repo.id,
repo.path,
review.number
]
)
const handleAutoMerge = useCallback(async () => {
if (isGitLab || !autoMergeAction) {
return
}
const enabled = autoMergeAction.kind === 'enable'
setMerging(true)
setActionError(null)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
enabled,
prRepo: githubPR?.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshReview()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Auto-merge update failed')
} finally {
setMerging(false)
}
}, [
githubPR?.prRepo,
isGitLab,
autoMergeAction,
onRefreshReview,
repo.id,
repo.path,
review.number
])
const handleReviewStateChange = useCallback(
async (nextState: 'open' | 'closed') => {
if (stateUpdating) {
return
}
const isClosing = nextState === 'closed'
const label = isClosing ? 'Close' : 'Reopen'
const confirmed = await confirm({
title: `${label} ${shortLabel} ${isGitLab ? '!' : '#'}${review.number}?`,
description: isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.a3d572a4de',
'This will close the {{value0}}.',
{ value0: reviewLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.78f5ff294c',
'This will reopen the {{value0}}.',
{ value0: reviewLabel }
),
confirmLabel: label,
confirmVariant: isClosing ? 'destructive' : 'default'
})
if (!confirmed) {
return
}
setStateUpdating(nextState)
setActionError(null)
try {
const result = isGitLab
? isClosing
? await window.api.gl.closeMR({ repoPath: repo.path, iid: review.number })
: await window.api.gl.reopenMR({ repoPath: repo.path, iid: review.number })
: await window.api.gh.updatePRState({
repoPath: repo.path,
repoId: repo.id,
prNumber: review.number,
updates: { state: nextState }
})
if (!result.ok) {
setActionError(result.error)
toast.error(result.error)
} else {
toast.success(
isClosing
? translate(
'auto.components.right.sidebar.HostedReviewActions.fa3ee9a515',
'{{value0}} closed',
{ value0: shortLabel }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.377269db6f',
'{{value0}} reopened',
{ value0: shortLabel }
)
)
await onRefreshReview()
}
} catch (err) {
const message =
err instanceof Error ? err.message : `Failed to ${label.toLowerCase()} ${reviewLabel}`
setActionError(message)
toast.error(message)
} finally {
setStateUpdating(null)
}
},
[
confirm,
isGitLab,
onRefreshReview,
repo.id,
repo.path,
review.number,
reviewLabel,
shortLabel,
stateUpdating
]
)
const handleCloseReview = useCallback(async () => {
await handleReviewStateChange('closed')
}, [handleReviewStateChange])
const handleReopenReview = useCallback(async () => {
await handleReviewStateChange('open')
}, [handleReviewStateChange])
return {
merging,
stateUpdating,
actionError,
handleMerge,
handleAutoMerge,
handleCloseReview,
handleReopenReview
}
}
@@ -6,13 +6,9 @@ import type { Repo, Worktree } from '../../../../shared/types'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import {
canInspectLocalMcpConfigRoot,
getMcpConfigCandidateParentDir,
getMcpConfigParentDirs,
inspectMcpConfigContent,
MCP_CONFIG_CANDIDATES,
MCP_STARTER_CONFIG,
selectExistingMcpConfigCandidates,
type McpConfigDirectoryEntry
MCP_STARTER_CONFIG
} from '../../../../shared/mcp-config'
import { useAppStore } from '../../store'
import { joinPath } from '../../lib/path'
@@ -20,6 +16,7 @@ import { extractIpcErrorMessage } from '../../lib/ipc-error'
import { Button } from '../ui/button'
import { isWindowsUserAgent } from '../terminal-pane/pane-helpers'
import { McpConfigFileRow, type LoadedMcpConfigInspection } from './McpConfigFileRow'
import { loadMcpConfigInspections } from './mcp-config-inspection'
import { translate } from '@/i18n/i18n'
type McpConfigSectionProps = {
@@ -28,11 +25,6 @@ type McpConfigSectionProps = {
const EMPTY_WORKTREES: Worktree[] = []
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /ENOENT|no such file|not found/i.test(message)
}
function countServers(configs: LoadedMcpConfigInspection[]): number {
return configs.reduce((sum, config) => sum + config.servers.length, 0)
}
@@ -138,81 +130,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele
return
}
const entriesByRelativeDir = new Map<string, readonly McpConfigDirectoryEntry[]>()
const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId })
entriesByRelativeDir.set('', rootEntries)
const rootDirectoryNames = new Set(
rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name)
)
const unreadableParentDirMessages = new Map<string, string>()
await Promise.all(
getMcpConfigParentDirs().map(async (relativeDir) => {
if (!rootDirectoryNames.has(relativeDir)) {
return
}
try {
const entries = await window.api.fs.readDir({
dirPath: joinPath(targetRootPath, relativeDir),
connectionId
})
entriesByRelativeDir.set(relativeDir, entries)
} catch (error) {
unreadableParentDirMessages.set(
relativeDir,
extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`)
)
}
})
)
const existingRelativePaths = new Set(
selectExistingMcpConfigCandidates(entriesByRelativeDir).map(
(candidate) => candidate.relativePath
)
)
const next = await Promise.all(
MCP_CONFIG_CANDIDATES.map(async (candidate): Promise<LoadedMcpConfigInspection> => {
const absolutePath = joinPath(targetRootPath, candidate.relativePath)
const parentDirReadError = unreadableParentDirMessages.get(
getMcpConfigCandidateParentDir(candidate)
)
if (parentDirReadError) {
return {
...inspectMcpConfigContent(candidate, null),
exists: false,
status: 'invalid',
absolutePath,
readError: parentDirReadError
}
}
if (!existingRelativePaths.has(candidate.relativePath)) {
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
}
try {
const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId })
const inspection = inspectMcpConfigContent(
candidate,
result.isBinary ? '' : result.content
)
return { ...inspection, absolutePath }
} catch (error) {
if (isMissingFileError(error)) {
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
}
return {
...inspectMcpConfigContent(candidate, null),
exists: false,
status: 'invalid',
absolutePath,
readError: extractIpcErrorMessage(error, 'Unable to read config file.')
}
}
})
)
const next = await loadMcpConfigInspections(targetRootPath, connectionId)
if (mountedRef.current) {
setConfigs(next)
}
@@ -0,0 +1,94 @@
import {
getMcpConfigCandidateParentDir,
getMcpConfigParentDirs,
inspectMcpConfigContent,
MCP_CONFIG_CANDIDATES,
selectExistingMcpConfigCandidates,
type McpConfigDirectoryEntry
} from '../../../../shared/mcp-config'
import { joinPath } from '../../lib/path'
import { extractIpcErrorMessage } from '../../lib/ipc-error'
import type { LoadedMcpConfigInspection } from './McpConfigFileRow'
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /ENOENT|no such file|not found/i.test(message)
}
export async function loadMcpConfigInspections(
targetRootPath: string,
connectionId: string | undefined
): Promise<LoadedMcpConfigInspection[]> {
const entriesByRelativeDir = new Map<string, readonly McpConfigDirectoryEntry[]>()
const rootEntries = await window.api.fs.readDir({ dirPath: targetRootPath, connectionId })
entriesByRelativeDir.set('', rootEntries)
const rootDirectoryNames = new Set(
rootEntries.filter((entry) => entry.isDirectory).map((entry) => entry.name)
)
const unreadableParentDirMessages = new Map<string, string>()
await Promise.all(
getMcpConfigParentDirs().map(async (relativeDir) => {
if (!rootDirectoryNames.has(relativeDir)) {
return
}
try {
const entries = await window.api.fs.readDir({
dirPath: joinPath(targetRootPath, relativeDir),
connectionId
})
entriesByRelativeDir.set(relativeDir, entries)
} catch (error) {
unreadableParentDirMessages.set(
relativeDir,
extractIpcErrorMessage(error, `Unable to inspect ${relativeDir}.`)
)
}
})
)
const existingRelativePaths = new Set(
selectExistingMcpConfigCandidates(entriesByRelativeDir).map(
(candidate) => candidate.relativePath
)
)
return Promise.all(
MCP_CONFIG_CANDIDATES.map(async (candidate): Promise<LoadedMcpConfigInspection> => {
const absolutePath = joinPath(targetRootPath, candidate.relativePath)
const parentDirReadError = unreadableParentDirMessages.get(
getMcpConfigCandidateParentDir(candidate)
)
if (parentDirReadError) {
return {
...inspectMcpConfigContent(candidate, null),
exists: false,
status: 'invalid',
absolutePath,
readError: parentDirReadError
}
}
if (!existingRelativePaths.has(candidate.relativePath)) {
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
}
try {
const result = await window.api.fs.readFile({ filePath: absolutePath, connectionId })
const inspection = inspectMcpConfigContent(candidate, result.isBinary ? '' : result.content)
return { ...inspection, absolutePath }
} catch (error) {
if (isMissingFileError(error)) {
return { ...inspectMcpConfigContent(candidate, null), absolutePath }
}
return {
...inspectMcpConfigContent(candidate, null),
exists: false,
status: 'invalid',
absolutePath,
readError: extractIpcErrorMessage(error, 'Unable to read config file.')
}
}
})
)
}