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.' - )} -

- -
- - -
-
- ) -} - 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.' + )} +

+ +
+ + +
+
+ ) +} 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.') + } + } + }) + ) +}