From 1735fda8ad212625eb17a0833f485c3f35adcb0f Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 11 Jun 2026 10:53:37 -0700
Subject: [PATCH] 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.
---
.../src/components/mobile/MobileHero.tsx | 86 +------
.../mobile/MobileHeroPairedDevices.tsx | 84 +++++++
.../src/components/onboarding/ThemeStep.tsx | 73 +-----
.../onboarding/theme-chrome-preview.tsx | 74 ++++++
.../right-sidebar/HostedReviewActions.tsx | 205 ++--------------
.../use-hosted-review-actions.ts | 227 ++++++++++++++++++
.../components/settings/McpConfigSection.tsx | 88 +------
.../settings/mcp-config-inspection.ts | 94 ++++++++
8 files changed, 507 insertions(+), 424 deletions(-)
create mode 100644 src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx
create mode 100644 src/renderer/src/components/onboarding/theme-chrome-preview.tsx
create mode 100644 src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts
create mode 100644 src/renderer/src/components/settings/mcp-config-inspection.ts
diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx
index 74a50de9d57..8039cbe6e87 100644
--- a/src/renderer/src/components/mobile/MobileHero.tsx
+++ b/src/renderer/src/components/mobile/MobileHero.tsx
@@ -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 (
-
-
-
- {translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
-
-
-
- {devices.length === 1
- ? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
- : translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
-
-
- {translate(
- 'auto.components.mobile.MobileHero.266c18c105',
- 'Open Orca Mobile to pick up where you left off, or pair another device.'
- )}
-
-
- {devices.map((device) => {
- const revoking = revokingDeviceIds.includes(device.deviceId)
- return (
- -
-
-
-
-
-
{device.name}
-
- {translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
- {new Date(device.pairedAt).toLocaleDateString()}
-
-
-
-
- )
- })}
-
-
-
-
-
-
- )
-}
-
type HeroFlowProps = {
stepIdx: StepIndex
platform: Platform
diff --git a/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx b/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx
new file mode 100644
index 00000000000..4202c92ab51
--- /dev/null
+++ b/src/renderer/src/components/mobile/MobileHeroPairedDevices.tsx
@@ -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 (
+
+
+
+ {translate('auto.components.mobile.MobileHero.5410d55d79', 'Orca Mobile')}
+
+
+
+ {devices.length === 1
+ ? translate('auto.components.mobile.MobileHero.051978a785', 'Your phone is paired.')
+ : translate('auto.components.mobile.MobileHero.d0b52871ce', 'Your phones are paired.')}
+
+
+ {translate(
+ 'auto.components.mobile.MobileHero.266c18c105',
+ 'Open Orca Mobile to pick up where you left off, or pair another device.'
+ )}
+
+
+ {devices.map((device) => {
+ const revoking = revokingDeviceIds.includes(device.deviceId)
+ return (
+ -
+
+
+
+
+
{device.name}
+
+ {translate('auto.components.mobile.MobileHero.94829abdb1', 'Paired')}{' '}
+ {new Date(device.pairedAt).toLocaleDateString()}
+
+
+
+
+ )
+ })}
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/onboarding/ThemeStep.tsx b/src/renderer/src/components/onboarding/ThemeStep.tsx
index bb00668dc3a..8bc20f89215 100644
--- a/src/renderer/src/components/onboarding/ThemeStep.tsx
+++ b/src/renderer/src/components/onboarding/ThemeStep.tsx
@@ -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 (
-
- )
- }
- return
-}
-
-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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
function humanFields(diff: Partial): 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
diff --git a/src/renderer/src/components/onboarding/theme-chrome-preview.tsx b/src/renderer/src/components/onboarding/theme-chrome-preview.tsx
new file mode 100644
index 00000000000..68ef3120ef8
--- /dev/null
+++ b/src/renderer/src/components/onboarding/theme-chrome-preview.tsx
@@ -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 (
+
+ )
+ }
+ return
+}
+
+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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx
index 1338b5586e6..3c45dab1d0c 100644
--- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx
+++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx
@@ -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(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.
diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts
new file mode 100644
index 00000000000..525d2460f7f
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts
@@ -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
+}): {
+ merging: boolean
+ stateUpdating: 'open' | 'closed' | null
+ actionError: string | null
+ handleMerge: (method?: GitHubPRMergeMethod) => Promise
+ handleAutoMerge: () => Promise
+ handleCloseReview: () => Promise
+ handleReopenReview: () => Promise
+} {
+ const confirm = useConfirmationDialog()
+ const [merging, setMerging] = useState(false)
+ const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
+ const [actionError, setActionError] = useState(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
+ }
+}
diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx
index bbad0996cf1..6e3857825b2 100644
--- a/src/renderer/src/components/settings/McpConfigSection.tsx
+++ b/src/renderer/src/components/settings/McpConfigSection.tsx
@@ -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()
- 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()
- 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 => {
- 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)
}
diff --git a/src/renderer/src/components/settings/mcp-config-inspection.ts b/src/renderer/src/components/settings/mcp-config-inspection.ts
new file mode 100644
index 00000000000..50d5b59b1c7
--- /dev/null
+++ b/src/renderer/src/components/settings/mcp-config-inspection.ts
@@ -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 {
+ const entriesByRelativeDir = new Map()
+ 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()
+ 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 => {
+ 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.')
+ }
+ }
+ })
+ )
+}