"use client" import { useState, useRef, useCallback, useMemo, useEffect } from "react" import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event" import { GitBranch, ChevronDown, ChevronRight, ArrowDownToLine, Upload, GitBranchPlus, GitCommitHorizontal, Archive, ArchiveRestore, GitFork, GitMerge, GitPullRequestArrow, Trash2, Loader2, RefreshCw, FolderGit2, FolderOpen, ArrowLeftRight, Globe, } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { ScrollArea } from "@/components/ui/scroll-area" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { open } from "@tauri-apps/plugin-dialog" import { gitInit, gitPull, gitFetch, gitNewBranch, gitWorktreeAdd, gitCheckout, gitListAllBranches, gitMerge, gitRebase, gitDeleteBranch, openFolderWindow, openCommitWindow, setFolderParentBranch, openStashWindow, openPushWindow, } from "@/lib/tauri" import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog" import { ConflictDialog } from "@/components/layout/conflict-dialog" import { StashDialog } from "@/components/layout/stash-dialog" import { disposeTauriListener } from "@/lib/tauri-listener" import { toErrorMessage } from "@/lib/app-error" import type { GitBranchList, GitConflictInfo } from "@/lib/types" import { toast } from "sonner" import { useFolderContext } from "@/contexts/folder-context" import { useTaskContext } from "@/contexts/task-context" import { useAlertContext } from "@/contexts/alert-context" import { useGitCredential } from "@/contexts/git-credential-context" interface BranchDropdownProps { branch: string | null parentBranch: string | null onBranchChange: () => void } type ConfirmAction = { type: "merge" | "rebase" | "delete" | "forceDelete" branchName: string } interface GitCommitSucceededEventPayload { folder_id: number committed_files: number } interface GitPushSucceededEventPayload { folder_id: number pushed_commits: number upstream_set: boolean } export function BranchDropdown({ branch, parentBranch, onBranchChange, }: BranchDropdownProps) { const t = useTranslations("Folder.branchDropdown") const tCommon = useTranslations("Folder.common") const { folder } = useFolderContext() const folderPath = folder?.path ?? "" const { addTask, updateTask, removeTask } = useTaskContext() const { pushAlert } = useAlertContext() const { withCredentialRetry } = useGitCredential() const [branchList, setBranchList] = useState({ local: [], remote: [], worktree_branches: [], }) const [newBranchOpen, setNewBranchOpen] = useState(false) const [newBranchName, setNewBranchName] = useState("") const [loading, setLoading] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) const [branchLoading, setBranchLoading] = useState(false) const [localOpen, setLocalOpen] = useState(false) const [remoteOpen, setRemoteOpen] = useState(false) const [confirmAction, setConfirmAction] = useState(null) const [expandedBranch, setExpandedBranch] = useState(null) const [worktreeOpen, setWorktreeOpen] = useState(false) const [worktreeBranchName, setWorktreeBranchName] = useState("") const [worktreePath, setWorktreePath] = useState("") const [manageRemotesOpen, setManageRemotesOpen] = useState(false) const [stashDialogOpen, setStashDialogOpen] = useState(false) const [conflictInfo, setConflictInfo] = useState(null) const taskSeq = useRef(0) const worktreeBranchSet = useMemo( () => new Set(branchList.worktree_branches), [branchList.worktree_branches] ) const groupedRemoteBranches = useMemo(() => { const groups: Record = {} for (const b of branchList.remote) { const slashIndex = b.indexOf("/") const remoteName = slashIndex > 0 ? b.substring(0, slashIndex) : "origin" if (!groups[remoteName]) groups[remoteName] = [] groups[remoteName].push(b) } return groups }, [branchList.remote]) const remoteNames = Object.keys(groupedRemoteBranches) const hasMultipleRemotes = remoteNames.length > 1 useEffect(() => { if (!folder) return let unlisten: UnlistenFn | null = null listen( "folder://git-commit-succeeded", (event) => { if (event.payload.folder_id !== folder.id) return toast.success(t("toasts.commitCodeCompleted"), { description: t("toasts.committedFiles", { count: event.payload.committed_files, }), }) onBranchChange() } ) .then((fn) => { unlisten = fn }) .catch((err) => { console.error("[BranchDropdown] failed to listen commit event:", err) }) return () => { disposeTauriListener(unlisten, "BranchDropdown.gitCommitSucceeded") } }, [folder, onBranchChange, t]) useEffect(() => { if (!folder) return let unlisten: UnlistenFn | null = null listen( "folder://git-push-succeeded", (event) => { if (event.payload.folder_id !== folder.id) return const { pushed_commits, upstream_set } = event.payload let description: string if (upstream_set) { description = pushed_commits === 0 ? t("toasts.upstreamSet") : t("toasts.upstreamSetAndPushed", { count: pushed_commits }) } else if (pushed_commits === 0) { description = t("toasts.noCommitsToPush") } else { description = t("toasts.pushedCommits", { count: pushed_commits }) } toast.success(t("toasts.pushCodeCompleted"), { description }) onBranchChange() } ) .then((fn) => { unlisten = fn }) .catch((err) => { console.error("[BranchDropdown] failed to listen push event:", err) }) return () => { disposeTauriListener(unlisten, "BranchDropdown.gitPushSucceeded") } }, [folder, onBranchChange, t]) async function runGitTask( label: string, action: () => Promise, getSuccessDescription?: (result: T) => string | false | undefined, onError?: (errorMsg: string) => boolean ) { const taskId = `git-${++taskSeq.current}-${Date.now()}` setLoading(true) addTask(taskId, label) updateTask(taskId, { status: "running" }) try { const result = await action() const successDescription = getSuccessDescription?.(result) updateTask(taskId, { status: "completed" }) onBranchChange() void emit("folder://git-branch-changed", { folder_id: folder?.id, }) if (successDescription !== false) { toast.success( t("toasts.taskCompleted", { label }), successDescription ? { description: successDescription, } : undefined ) } } catch (err) { removeTask(taskId) const errorMsg = toErrorMessage(err) if (onError?.(errorMsg)) { return } const errorTitle = t("toasts.taskFailed", { label }) pushAlert("error", errorTitle, errorMsg) toast.error(errorTitle, { description: errorMsg }) } finally { setLoading(false) } } const loadAllBranches = useCallback(async () => { setBranchLoading(true) try { const list = await gitListAllBranches(folderPath) setBranchList(list) } catch { setBranchList({ local: [], remote: [], worktree_branches: [] }) } finally { setBranchLoading(false) } }, [folderPath]) function handleDropdownOpenChange(open: boolean) { setDropdownOpen(open) if (open && branch !== null) { loadAllBranches() } if (!open) { setLocalOpen(false) setRemoteOpen(false) setExpandedBranch(null) } } async function handleNewBranch() { const name = newBranchName.trim() if (!name) return setNewBranchOpen(false) setNewBranchName("") await runGitTask(t("tasks.newBranch", { name }), () => gitNewBranch(folderPath, name) ) } function handleOpenWorktreeDialog() { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" let random = "" for (let i = 0; i < 6; i++) { random += chars[Math.floor(Math.random() * chars.length)] } const folderName = folderPath.split("/").filter(Boolean).pop() ?? "project" const currentBranch = branch ?? "main" const defaultBranch = `cv-${currentBranch}-${random}` const parentDir = folderPath.substring(0, folderPath.lastIndexOf("/")) setWorktreeBranchName(defaultBranch) setWorktreePath(`${parentDir}/${folderName}-${currentBranch}-${random}`) setWorktreeOpen(true) } function handleWorktreeBranchChange(name: string) { setWorktreeBranchName(name) } async function handleBrowseWorktreePath() { const selected = await open({ directory: true, multiple: false }) if (selected) { setWorktreePath(selected) } } async function handleNewWorktree() { const name = worktreeBranchName.trim() const wtPath = worktreePath.trim() if (!name || !wtPath) return setWorktreeOpen(false) await runGitTask(t("tasks.newWorktree", { name }), async () => { await gitWorktreeAdd(folderPath, name, wtPath) await openFolderWindow(wtPath) await setFolderParentBranch(wtPath, branch) }) } function handleMergeParent() { if (!parentBranch) return setConfirmAction({ type: "merge", branchName: parentBranch }) } async function handleCheckout(branchName: string) { setDropdownOpen(false) await runGitTask(t("tasks.checkoutTo", { branchName }), () => gitCheckout(folderPath, branchName) ) } async function handleCheckoutRemote(remoteBranch: string) { const localName = remoteBranch.replace(/^[^/]+\//, "") setDropdownOpen(false) await runGitTask(t("tasks.checkoutTo", { branchName: localName }), () => gitCheckout(folderPath, localName) ) } async function handleConfirm() { if (!confirmAction) return const { type, branchName } = confirmAction setConfirmAction(null) switch (type) { case "merge": await runGitTask( t("tasks.mergeBranch", { branchName }), () => gitMerge(folderPath, branchName), (result) => { if (result.conflict?.has_conflicts) { setConflictInfo(result.conflict) return false } if (result.merged_commits === 0) { return t("toasts.mergeNoNewCommits", { branchName }) } return t("toasts.mergedCommits", { count: result.merged_commits }) } ) break case "rebase": await runGitTask( t("tasks.rebaseTo", { branchName }), () => gitRebase(folderPath, branchName), (result) => { if (result.conflict?.has_conflicts) { setConflictInfo(result.conflict) return false } return undefined } ) break case "delete": await runGitTask( t("tasks.deleteBranch", { branchName }), () => gitDeleteBranch(folderPath, branchName), undefined, (errorMsg) => { if (/not fully merged/i.test(errorMsg)) { setConfirmAction({ type: "forceDelete", branchName }) return true } return false } ) break case "forceDelete": await runGitTask(t("tasks.deleteBranch", { branchName }), () => gitDeleteBranch(folderPath, branchName, true) ) break } } function getConfirmTitle() { if (!confirmAction) return "" switch (confirmAction.type) { case "merge": return t("confirm.mergeTitle") case "rebase": return t("confirm.rebaseTitle") case "delete": return t("confirm.deleteTitle") case "forceDelete": return t("confirm.forceDeleteTitle") } } function getConfirmDescription() { if (!confirmAction) return "" switch (confirmAction.type) { case "merge": return t("confirm.mergeDescription", { branchName: confirmAction.branchName, currentBranch: branch ?? "-", }) case "rebase": return t("confirm.rebaseDescription", { currentBranch: branch ?? "-", branchName: confirmAction.branchName, }) case "delete": return t("confirm.deleteDescription", { branchName: confirmAction.branchName, }) case "forceDelete": return t("confirm.forceDeleteDescription", { branchName: confirmAction.branchName, }) } } function renderBranchItem( b: string, isRemote: boolean, displayName?: string ) { const label = displayName ?? b const isCurrent = b === branch const isWorktree = worktreeBranchSet.has( isRemote ? b.replace(/^[^/]+\//, "") : b ) const BranchIcon = isWorktree ? FolderGit2 : GitBranch if (isCurrent) { return (
{label} {t("current")}
) } return ( { if (!open) setExpandedBranch(null) }} > setExpandedBranch(expandedBranch === b ? null : b)} onPointerMove={(e) => { e.preventDefault() if (expandedBranch !== null && expandedBranch !== b) { setExpandedBranch(null) if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } } }} onPointerLeave={(e) => e.preventDefault()} > {label} { if (isRemote) { handleCheckoutRemote(b) } else { handleCheckout(b) } }} > {t("switchToBranch")} { setDropdownOpen(false) setConfirmAction({ type: "merge", branchName: b }) }} > {t("mergeBranchIntoCurrent", { branchName: b, currentBranch: branch ?? "-", })} { setDropdownOpen(false) setConfirmAction({ type: "rebase", branchName: b }) }} > {t("rebaseCurrentToBranch", { currentBranch: branch ?? "-", branchName: b, })} {!isRemote && ( <> { setDropdownOpen(false) setConfirmAction({ type: "delete", branchName: b }) }} > {t("deleteBranch")} )} ) } if (branch === null) { return ( runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath)) } > {t("initGitRepo")} ) } return ( <> runGitTask( t("tasks.pullCode"), () => withCredentialRetry((creds) => gitPull(folderPath, creds), { folderPath, }), (result) => { if (result.conflict?.has_conflicts) { setConflictInfo(result.conflict) return false } if (result.updated_files === 0) { return t("toasts.allFilesUpToDate") } return t("toasts.updatedFiles", { count: result.updated_files, }) } ) } > {t("pullCode")} runGitTask(t("tasks.fetchInfo"), () => withCredentialRetry((creds) => gitFetch(folderPath, creds), { folderPath, }) ) } > {t("fetchRemoteBranches")} { if (!folder) return setDropdownOpen(false) openCommitWindow(folder.id).catch((err) => { const title = t("toasts.openCommitWindowFailed") const msg = toErrorMessage(err) pushAlert("error", title, msg) toast.error(title, { description: msg }) }) }} > {t("openCommitWindow")} { if (!folder) return setDropdownOpen(false) openPushWindow(folder.id).catch((err) => { const title = t("toasts.openPushWindowFailed") const msg = toErrorMessage(err) pushAlert("error", title, msg) toast.error(title, { description: msg }) }) }} > {t("pushCode")} { setNewBranchName("") setNewBranchOpen(true) }} > {t("newBranch")} {t("newWorktree")} { setDropdownOpen(false) setStashDialogOpen(true) }} > {t("stashChanges")} { if (!folder) return openStashWindow(folder.id).catch((err) => { const msg = toErrorMessage(err) pushAlert("error", t("stashPop"), msg) }) }} > {t("stashPop")} { setDropdownOpen(false) setManageRemotesOpen(true) }} > {t("manageRemotes")} {branchLoading ? (
) : ( {t("localBranches", { count: branchList.local.length })} {branchList.local.length === 0 ? ( {t("noLocalBranches")} ) : ( branchList.local.map((b) => renderBranchItem(b, false)) )} {t("remoteBranches", { count: branchList.remote.length })} {branchList.remote.length === 0 ? ( {t("noRemoteBranches")} ) : hasMultipleRemotes ? ( remoteNames.map((remoteName) => ( {remoteName} ( {groupedRemoteBranches[remoteName].length}) {groupedRemoteBranches[remoteName].map((b) => renderBranchItem( b, true, b.substring(remoteName.length + 1) ) )} )) ) : ( branchList.remote.map((b) => { const slashIndex = b.indexOf("/") const shortName = slashIndex > 0 ? b.substring(slashIndex + 1) : b return renderBranchItem(b, true, shortName) }) )} )}
{parentBranch && ( )} { if (!open) setConfirmAction(null) }} > {getConfirmTitle()} {getConfirmDescription()} {tCommon("cancel")} {tCommon("confirm")} {t("dialogs.newBranchTitle")} {t("dialogs.newBranchDescription", { branch: branch ?? "-" })} setNewBranchName(e.target.value)} onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.key === "Process") return if (e.key === "Enter") handleNewBranch() }} autoFocus /> {t("dialogs.newWorktreeTitle")} {t("dialogs.newWorktreeDescription", { branch: branch ?? "-" })}
handleWorktreeBranchChange(e.target.value)} onKeyDown={(e) => { if (e.nativeEvent.isComposing || e.key === "Process") return if (e.key === "Enter") handleNewWorktree() }} autoFocus />
setWorktreePath(e.target.value)} className="flex-1" />
loadAllBranches()} /> setConflictInfo(null)} onResolved={onBranchChange} /> setStashDialogOpen(false)} onStashed={onBranchChange} /> ) }