mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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.')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user