orca/src/renderer/src/components/sidebar/AddRepoDialog.tsx

411 lines
14 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FolderOpen, ArrowLeft, Globe, Monitor } from 'lucide-react'
import { useAppStore } from '@/store'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps'
import { CreateStep, useCreateRepo } from './AddRepoCreateStep'
import { SetupStep } from './AddRepoSetupStep'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { Repo, Worktree } from '../../../../shared/types'
const AddRepoDialog = React.memo(function AddRepoDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const closeModal = useAppStore((s) => s.closeModal)
const addRepo = useAppStore((s) => s.addRepo)
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const openModal = useAppStore((s) => s.openModal)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add')
const [addedRepo, setAddedRepo] = useState<Repo | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [cloneUrl, setCloneUrl] = useState('')
const [cloneDestination, setCloneDestination] = useState('')
const [isCloning, setIsCloning] = useState(false)
const [cloneError, setCloneError] = useState<string | null>(null)
const [cloneProgress, setCloneProgress] = useState<{ phase: string; percent: number } | null>(
null
)
// Why: monotonic ID so stale clone callbacks can detect they were superseded.
const cloneGenRef = useRef(0)
const {
sshTargets,
selectedTargetId,
remotePath,
remoteError,
isAddingRemote,
setSelectedTargetId,
setRemotePath,
setRemoteError,
resetRemoteState,
handleOpenRemoteStep,
handleAddRemoteRepo,
handleConnectTarget
} = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
const {
createName,
createParent,
createKind,
createError,
isCreating,
setCreateName,
setCreateKind,
setCreateError,
resetCreateState,
handlePickParent,
handleCreate
} = useCreateRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
useEffect(() => {
if (!isCloning) {
return
}
return window.api.repos.onCloneProgress(setCloneProgress)
}, [isCloning])
const isOpen = activeModal === 'add-repo'
const repoId = addedRepo?.id ?? ''
const worktrees = useMemo(() => {
return worktreesByRepo[repoId] ?? []
}, [worktreesByRepo, repoId])
// Why: sort by recent activity with alphabetical fallback.
const sortedWorktrees = useMemo(() => {
return [...worktrees].sort((a, b) => {
if (a.lastActivityAt !== b.lastActivityAt) {
return b.lastActivityAt - a.lastActivityAt
}
return a.displayName.localeCompare(b.displayName)
})
}, [worktrees])
const resetState = useCallback(() => {
cloneGenRef.current++
// Why: kill the git clone process if one is running, so backing out
// or closing the dialog doesn't leave a clone running on disk.
void window.api.repos.cloneAbort()
setStep('add')
setAddedRepo(null)
setIsAdding(false)
setCloneUrl('')
setCloneDestination('')
setIsCloning(false)
setCloneError(null)
setCloneProgress(null)
resetCreateState()
resetRemoteState()
}, [resetRemoteState, resetCreateState])
// Why: reset state on close so reopening doesn't show stale step/repo.
useEffect(() => {
if (!isOpen) {
resetState()
}
}, [isOpen, resetState])
const isInputStep = step === 'add' || step === 'clone' || step === 'remote' || step === 'create'
const handleBrowse = useCallback(async () => {
setIsAdding(true)
try {
const repo = await addRepo()
if (repo && isGitRepoKind(repo)) {
setAddedRepo(repo)
await fetchWorktrees(repo.id)
setStep('setup')
} else if (repo) {
// Why: non-git folders have no worktrees — close immediately.
closeModal()
}
} finally {
setIsAdding(false)
}
}, [addRepo, fetchWorktrees, closeModal])
const handlePickDestination = useCallback(async () => {
const dir = await window.api.repos.pickDirectory()
if (dir) {
setCloneDestination(dir)
setCloneError(null)
}
}, [])
const handleClone = useCallback(async () => {
const trimmedUrl = cloneUrl.trim()
if (!trimmedUrl || !cloneDestination.trim()) {
return
}
const gen = ++cloneGenRef.current
setIsCloning(true)
setCloneError(null)
setCloneProgress(null)
try {
const repo = (await window.api.repos.clone({
url: trimmedUrl,
destination: cloneDestination.trim()
})) as Repo
// Why: if the user closed the dialog or clicked Back during the clone,
// cloneGenRef will have been bumped by resetState. Ignore this stale result.
if (gen !== cloneGenRef.current) {
return
}
toast.success('Repository cloned', { description: repo.displayName })
// Why: eagerly upsert so step 2 finds the repo before the IPC event.
const state = useAppStore.getState()
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
if (existingIdx === -1) {
useAppStore.setState({ repos: [...state.repos, repo] })
} else {
const updated = [...state.repos]
updated[existingIdx] = repo
useAppStore.setState({ repos: updated })
}
setAddedRepo(repo)
await fetchWorktrees(repo.id)
setStep('setup')
} catch (err) {
if (gen !== cloneGenRef.current) {
return
}
const message = err instanceof Error ? err.message : String(err)
setCloneError(message)
} finally {
if (gen === cloneGenRef.current) {
setIsCloning(false)
}
}
}, [cloneUrl, cloneDestination, fetchWorktrees])
const handleOpenWorktree = useCallback(
(worktree: Worktree) => {
activateAndRevealWorktree(worktree.id)
closeModal()
},
[closeModal]
)
const handleCreateWorktree = useCallback(() => {
// Why: small delay so the Add Project dialog close animation finishes before
// the composer modal takes focus; otherwise the dialog teardown can steal
// the first focus frame from the composer's prompt textarea.
closeModal()
setTimeout(() => {
openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' })
}, 150)
}, [closeModal, openModal, repoId])
const handleConfigureRepo = useCallback(() => {
closeModal()
openSettingsTarget({ pane: 'repo', repoId })
openSettingsPage()
}, [closeModal, openSettingsTarget, openSettingsPage, repoId])
// Why: handleBack reuses resetState which already aborts clones and resets all fields.
const handleBack = resetState
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
closeModal()
resetState()
}
}}
>
<DialogContent className="sm:max-w-lg">
{/* Step indicator row — back button (step 2 only), dots, X is rendered by DialogContent */}
<div className="flex items-center justify-center -mt-1">
{(step === 'clone' || step === 'remote' || step === 'create') && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={handleBack}
>
<ArrowLeft className="size-3" />
Back
</button>
)}
{step === 'setup' && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={handleBack}
>
<ArrowLeft className="size-3" />
Add another project
</button>
)}
<div className="flex items-center gap-1.5">
<div
className={`size-1.5 rounded-full transition-colors ${isInputStep ? 'bg-foreground' : 'bg-muted-foreground/30'}`}
/>
<div
className={`size-1.5 rounded-full transition-colors ${step === 'setup' ? 'bg-foreground' : 'bg-muted-foreground/30'}`}
/>
</div>
</div>
{step === 'add' ? (
<>
<DialogHeader>
<DialogTitle>Add a project</DialogTitle>
<DialogDescription>
{repos.length === 0
? 'Add a project to get started with Orca.'
: 'Add another project to manage with Orca.'}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-3 pt-2">
<Button
onClick={handleBrowse}
disabled={isAdding}
variant="outline"
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
>
<FolderOpen className="size-6 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Browse folder</p>
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
Local Git project or folder
</p>
</div>
</Button>
<Button
onClick={() => setStep('clone')}
variant="outline"
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
>
<Globe className="size-6 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Clone from URL</p>
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
Remote Git repository
</p>
</div>
</Button>
<Button
onClick={handleOpenRemoteStep}
variant="outline"
className="h-auto py-5 px-2 flex flex-col items-center gap-2 text-center border-border/80"
>
<Monitor className="size-6 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Remote project</p>
<p className="text-[11px] text-muted-foreground font-normal mt-0.5">
SSH connected target
</p>
</div>
</Button>
</div>
{/* Secondary link rather than a fourth card — create-from-scratch
is a less common path than importing. See orca#763. */}
<div className="flex items-center justify-center pt-1">
<button
type="button"
onClick={() => {
setCreateError(null)
setStep('create')
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Or start a new project from scratch
</button>
</div>
</>
) : step === 'remote' ? (
<RemoteStep
sshTargets={sshTargets}
selectedTargetId={selectedTargetId}
remotePath={remotePath}
remoteError={remoteError}
isAddingRemote={isAddingRemote}
onSelectTarget={(id) => {
setSelectedTargetId(id)
setRemoteError(null)
}}
onRemotePathChange={(value) => {
setRemotePath(value)
setRemoteError(null)
}}
onAdd={handleAddRemoteRepo}
onOpenSshSettings={() => {
closeModal()
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
openSettingsPage()
}}
onConnectTarget={handleConnectTarget}
/>
) : step === 'clone' ? (
<CloneStep
cloneUrl={cloneUrl}
cloneDestination={cloneDestination}
cloneError={cloneError}
cloneProgress={cloneProgress}
isCloning={isCloning}
onUrlChange={(value) => {
setCloneUrl(value)
setCloneError(null)
}}
onDestChange={(value) => {
setCloneDestination(value)
setCloneError(null)
}}
onPickDestination={handlePickDestination}
onClone={handleClone}
/>
) : step === 'create' ? (
<CreateStep
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
onNameChange={(value) => {
setCreateName(value)
setCreateError(null)
}}
onKindChange={(kind) => {
setCreateKind(kind)
setCreateError(null)
}}
onPickParent={handlePickParent}
onCreate={handleCreate}
/>
) : (
<SetupStep
repoName={addedRepo?.displayName ?? ''}
sortedWorktrees={sortedWorktrees}
onOpenWorktree={handleOpenWorktree}
onCreateWorktree={handleCreateWorktree}
onConfigureRepo={handleConfigureRepo}
onSkip={() => {
closeModal()
resetState()
}}
/>
)}
</DialogContent>
</Dialog>
)
})
export default AddRepoDialog