"use client" import { useState, useRef, useCallback, useMemo, useEffect } from "react" import { 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, gitPush, gitNewBranch, gitWorktreeAdd, gitCheckout, gitListAllBranches, gitMerge, gitRebase, gitDeleteBranch, gitStash, gitStashPop, openFolderWindow, openCommitWindow, setFolderParentBranch, } from "@/lib/tauri" import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog" import { disposeTauriListener } from "@/lib/tauri-listener" import type { GitBranchList } 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" interface BranchDropdownProps { branch: string | null parentBranch: string | null onBranchChange: () => void } type ConfirmAction = { type: "merge" | "rebase" | "delete" branchName: string } interface GitCommitSucceededEventPayload { folder_id: number committed_files: number } 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 [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 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]) async function runGitTask( label: string, action: () => Promise, getSuccessDescription?: (result: T) => string | undefined ) { 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() toast.success( t("toasts.taskCompleted", { label }), successDescription ? { description: successDescription, } : undefined ) } catch (err) { removeTask(taskId) const errorTitle = t("toasts.taskFailed", { label }) pushAlert("error", errorTitle, String(err)) toast.error(errorTitle, { description: String(err) }) } 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.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) ) break case "delete": await runGitTask(t("tasks.deleteBranch", { branchName }), () => gitDeleteBranch(folderPath, branchName) ) 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") } } 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, }) } } function renderBranchItem(b: string, isRemote: boolean) { const isCurrent = b === branch const isWorktree = worktreeBranchSet.has( isRemote ? b.replace(/^[^/]+\//, "") : b ) const BranchIcon = isWorktree ? FolderGit2 : GitBranch if (isCurrent) { return (
{b} {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()} > {b} { 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"), () => gitPull(folderPath), (result) => { if (result.updated_files === 0) { return t("toasts.allFilesUpToDate") } return t("toasts.updatedFiles", { count: result.updated_files, }) } ) } > {t("pullCode")} runGitTask(t("tasks.fetchInfo"), () => gitFetch(folderPath)) } > {t("fetchRemoteBranches")} { if (!folder) return setDropdownOpen(false) openCommitWindow(folder.id).catch((err) => { const title = t("toasts.openCommitWindowFailed") pushAlert("error", title, String(err)) toast.error(title, { description: String(err) }) }) }} > {t("openCommitWindow")} runGitTask( t("tasks.pushCode"), () => gitPush(folderPath), (result) => { if (result.upstream_set) { if (result.pushed_commits === 0) { return t("toasts.upstreamSet") } return t("toasts.upstreamSetAndPushed", { count: result.pushed_commits, }) } if (result.pushed_commits === 0) { return t("toasts.noCommitsToPush") } return t("toasts.pushedCommits", { count: result.pushed_commits, }) } ) } > {t("pushCode")} { setNewBranchName("") setNewBranchOpen(true) }} > {t("newBranch")} {t("newWorktree")} runGitTask(t("tasks.stashChanges"), () => gitStash(folderPath)) } > {t("stashChanges")} runGitTask(t("tasks.stashPop"), () => gitStashPop(folderPath)) } > {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) )} )) ) : ( branchList.remote.map((b) => renderBranchItem(b, true)) )} )}
{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()} /> ) }