Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View File

@@ -0,0 +1,752 @@
"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,
} 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 { 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 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 { folder } = useFolderContext()
const folderPath = folder?.path ?? ""
const { addTask, updateTask, removeTask } = useTaskContext()
const { pushAlert } = useAlertContext()
const [branchList, setBranchList] = useState<GitBranchList>({
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<ConfirmAction | null>(null)
const [expandedBranch, setExpandedBranch] = useState<string | null>(null)
const [worktreeOpen, setWorktreeOpen] = useState(false)
const [worktreeBranchName, setWorktreeBranchName] = useState("")
const [worktreePath, setWorktreePath] = useState("")
const taskSeq = useRef(0)
const worktreeBranchSet = useMemo(
() => new Set(branchList.worktree_branches),
[branchList.worktree_branches]
)
useEffect(() => {
if (!folder) return
let unlisten: UnlistenFn | null = null
listen<GitCommitSucceededEventPayload>(
"folder://git-commit-succeeded",
(event) => {
if (event.payload.folder_id !== folder.id) return
toast.success("提交代码完成", {
description: `已提交 ${event.payload.committed_files} 个文件`,
})
onBranchChange()
}
)
.then((fn) => {
unlisten = fn
})
.catch((err) => {
console.error("[BranchDropdown] failed to listen commit event:", err)
})
return () => {
if (unlisten) unlisten()
}
}, [folder, onBranchChange])
async function runGitTask<T>(
label: string,
action: () => Promise<T>,
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(
`${label} 完成`,
successDescription
? {
description: successDescription,
}
: undefined
)
} catch (err) {
removeTask(taskId)
pushAlert("error", `${label}失败`, String(err))
toast.error(`${label} 失败`, { 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(`新建分支 ${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(`新建工作树 ${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(`切换到 ${branchName}`, () =>
gitCheckout(folderPath, branchName)
)
}
async function handleCheckoutRemote(remoteBranch: string) {
const localName = remoteBranch.replace(/^[^/]+\//, "")
setDropdownOpen(false)
await runGitTask(`切换到 ${localName}`, () =>
gitCheckout(folderPath, localName)
)
}
async function handleConfirm() {
if (!confirmAction) return
const { type, branchName } = confirmAction
setConfirmAction(null)
switch (type) {
case "merge":
await runGitTask(
`合并 ${branchName}`,
() => gitMerge(folderPath, branchName),
(result) => {
if (result.merged_commits === 0) {
return `${branchName} 没有新的提交`
}
return `已合并 ${result.merged_commits} 个提交`
}
)
break
case "rebase":
await runGitTask(`变基到 ${branchName}`, () =>
gitRebase(folderPath, branchName)
)
break
case "delete":
await runGitTask(`删除分支 ${branchName}`, () =>
gitDeleteBranch(folderPath, branchName)
)
break
}
}
function getConfirmTitle() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return "合并分支"
case "rebase":
return "变基分支"
case "delete":
return "删除分支"
}
}
function getConfirmDescription() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return `确定将 ${confirmAction.branchName} 合并到当前分支 ${branch} 吗?`
case "rebase":
return `确定将当前分支 ${branch} 变基到 ${confirmAction.branchName} 吗?`
case "delete":
return `确定删除分支 ${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 (
<div
key={b}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm opacity-50 select-none"
>
<BranchIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{b}</span>
<span className="ml-auto text-xs"></span>
</div>
)
}
return (
<DropdownMenuSub
key={b}
open={expandedBranch === b}
onOpenChange={(open) => {
if (!open) setExpandedBranch(null)
}}
>
<DropdownMenuSubTrigger
className="hover:bg-accent hover:text-accent-foreground"
disabled={loading}
onClick={() => 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()}
>
<BranchIcon className="h-3.5 w-3.5" />
{b}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => {
if (isRemote) {
handleCheckoutRemote(b)
} else {
handleCheckout(b)
}
}}
>
<GitBranch className="h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "merge", branchName: b })
}}
>
<GitMerge className="h-3.5 w-3.5" /> {b} {branch}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "rebase", branchName: b })
}}
>
<GitPullRequestArrow className="h-3.5 w-3.5" /> {branch} {" "}
{b}
</DropdownMenuItem>
{!isRemote && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "delete", branchName: b })
}}
>
<Trash2 className="h-3.5 w-3.5" />
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
if (branch === null) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
<GitFork className="h-3 w-3 shrink-0" />
<span className="truncate"></span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask("初始化 Git 仓库", () => gitInit(folderPath))
}
>
<GitBranch className="h-3.5 w-3.5" />
Git
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{branch}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(
"更新代码",
() => gitPull(folderPath),
(result) => {
if (result.updated_files === 0) {
return "所有文件均为最新版本"
}
return `已更新 ${result.updated_files} 个文件`
}
)
}
>
<ArrowDownToLine className="h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask("获取信息", () => gitFetch(folderPath))
}
>
<RefreshCw className="h-3.5 w-3.5" />
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folder) return
setDropdownOpen(false)
openCommitWindow(folder.id).catch((err) => {
pushAlert("error", "打开提交窗口失败", String(err))
toast.error("打开提交窗口失败", { description: String(err) })
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(
"推送代码",
() => gitPush(folderPath),
(result) => {
if (result.upstream_set) {
if (result.pushed_commits === 0) {
return "已设置远程跟踪分支"
}
return `已设置远程跟踪分支并推送 ${result.pushed_commits} 个提交`
}
if (result.pushed_commits === 0) {
return "没有可推送的提交"
}
return `已推送 ${result.pushed_commits} 个提交`
}
)
}
>
<Upload className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setNewBranchName("")
setNewBranchOpen(true)
}}
>
<GitBranchPlus className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={handleOpenWorktreeDialog}
>
<FolderGit2 className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask("贮藏更改", () => gitStash(folderPath))
}
>
<Archive className="h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask("取消贮藏", () => gitStashPop(folderPath))
}
>
<ArchiveRestore className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{branchLoading ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="max-h-64">
<Collapsible open={localOpen} onOpenChange={setLocalOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
({branchList.local.length})
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.local.length === 0 ? (
<DropdownMenuItem disabled></DropdownMenuItem>
) : (
branchList.local.map((b) => renderBranchItem(b, false))
)}
</CollapsibleContent>
</Collapsible>
<Collapsible open={remoteOpen} onOpenChange={setRemoteOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
({branchList.remote.length})
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.remote.length === 0 ? (
<DropdownMenuItem disabled></DropdownMenuItem>
) : (
branchList.remote.map((b) => renderBranchItem(b, true))
)}
</CollapsibleContent>
</Collapsible>
</ScrollArea>
)}
</DropdownMenuContent>
</DropdownMenu>
{parentBranch && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-orange-500 dark:text-orange-400 hover:bg-accent hover:text-orange-600 dark:hover:text-orange-300 transition-colors cursor-default select-none"
disabled={loading}
onClick={handleMergeParent}
title={`当前分支从 ${parentBranch} 创建,点击合并 ${parentBranch} 到当前分支`}
>
<ArrowLeftRight className="h-3 w-3 shrink-0" />
<span className="truncate max-w-32">{parentBranch}</span>
</button>
)}
<AlertDialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open) setConfirmAction(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>
{getConfirmDescription()}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
variant={
confirmAction?.type === "delete" ? "destructive" : "default"
}
onClick={handleConfirm}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{branch}
</DialogDescription>
</DialogHeader>
<Input
placeholder="分支名称"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewBranch()
}}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setNewBranchOpen(false)}>
</Button>
<Button
disabled={!newBranchName.trim() || loading}
onClick={handleNewBranch}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={worktreeOpen} onOpenChange={setWorktreeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{branch}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="wt-branch"></Label>
<Input
id="wt-branch"
placeholder="分支名称"
value={worktreeBranchName}
onChange={(e) => handleWorktreeBranchChange(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewWorktree()
}}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="wt-path"></Label>
<div className="flex gap-2">
<Input
id="wt-path"
placeholder="工作树路径"
value={worktreePath}
onChange={(e) => setWorktreePath(e.target.value)}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={handleBrowseWorktreePath}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWorktreeOpen(false)}>
</Button>
<Button
disabled={
!worktreeBranchName.trim() || !worktreePath.trim() || loading
}
onClick={handleNewWorktree}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}