初步支持远程Git管理
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
ArrowLeftRight,
|
||||
Globe,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
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"
|
||||
@@ -131,11 +133,24 @@ export function BranchDropdown({
|
||||
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<string, string[]> = {}
|
||||
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
|
||||
@@ -611,6 +626,19 @@ export function BranchDropdown({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false)
|
||||
setManageRemotesOpen(true)
|
||||
}}
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{t("manageRemotes")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{branchLoading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
@@ -643,6 +671,20 @@ export function BranchDropdown({
|
||||
<DropdownMenuItem disabled>
|
||||
{t("noRemoteBranches")}
|
||||
</DropdownMenuItem>
|
||||
) : hasMultipleRemotes ? (
|
||||
remoteNames.map((remoteName) => (
|
||||
<Collapsible key={remoteName}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 pl-6 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
{remoteName} ({groupedRemoteBranches[remoteName].length})
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{groupedRemoteBranches[remoteName].map((b) =>
|
||||
renderBranchItem(b, true)
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))
|
||||
) : (
|
||||
branchList.remote.map((b) => renderBranchItem(b, true))
|
||||
)}
|
||||
@@ -782,6 +824,13 @@ export function BranchDropdown({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<RemoteManageDialog
|
||||
open={manageRemotesOpen}
|
||||
onOpenChange={setManageRemotesOpen}
|
||||
folderPath={folderPath}
|
||||
onSaved={() => loadAllBranches()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
250
src/components/layout/remote-manage-dialog.tsx
Normal file
250
src/components/layout/remote-manage-dialog.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
import {
|
||||
gitListRemotes,
|
||||
gitFetchRemote,
|
||||
gitAddRemote,
|
||||
gitRemoveRemote,
|
||||
gitSetRemoteUrl,
|
||||
} from "@/lib/tauri"
|
||||
|
||||
interface RemoteDraft {
|
||||
originalName: string | null
|
||||
originalUrl: string
|
||||
name: string
|
||||
url: string
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
interface RemoteManageDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
folderPath: string
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function RemoteManageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
folderPath,
|
||||
onSaved,
|
||||
}: RemoteManageDialogProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.dialogs")
|
||||
const tCommon = useTranslations("Folder.common")
|
||||
const [drafts, setDrafts] = useState<RemoteDraft[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadingRemotes, setLoadingRemotes] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<number, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setErrors({})
|
||||
setLoadingRemotes(true)
|
||||
gitListRemotes(folderPath)
|
||||
.then((remotes) => {
|
||||
setDrafts(
|
||||
remotes.map((r) => ({
|
||||
originalName: r.name,
|
||||
originalUrl: r.url,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
deleted: false,
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load remotes:", err)
|
||||
setDrafts([])
|
||||
})
|
||||
.finally(() => setLoadingRemotes(false))
|
||||
}
|
||||
}, [open, folderPath])
|
||||
|
||||
const addDraft = () => {
|
||||
setDrafts((prev) => [
|
||||
...prev,
|
||||
{ originalName: null, originalUrl: "", name: "", url: "", deleted: false },
|
||||
])
|
||||
}
|
||||
|
||||
const updateDraft = (
|
||||
index: number,
|
||||
field: "name" | "url",
|
||||
value: string
|
||||
) => {
|
||||
setDrafts((prev) =>
|
||||
prev.map((d, i) => (i === index ? { ...d, [field]: value } : d))
|
||||
)
|
||||
}
|
||||
|
||||
const removeDraft = (index: number) => {
|
||||
setDrafts((prev) =>
|
||||
prev.map((d, i) => (i === index ? { ...d, deleted: true } : d))
|
||||
)
|
||||
}
|
||||
|
||||
const extractError = (err: unknown): string => {
|
||||
if (err && typeof err === "object") {
|
||||
const e = err as Record<string, unknown>
|
||||
if (typeof e.detail === "string" && e.detail) return e.detail
|
||||
if (typeof e.message === "string" && e.message) return e.message
|
||||
}
|
||||
return String(err)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setErrors({})
|
||||
const newErrors: Record<number, string> = {}
|
||||
try {
|
||||
// Process deletions first
|
||||
for (const draft of drafts) {
|
||||
if (draft.deleted && draft.originalName != null) {
|
||||
await gitRemoveRemote(folderPath, draft.originalName)
|
||||
}
|
||||
}
|
||||
// Process additions
|
||||
for (let i = 0; i < drafts.length; i++) {
|
||||
const draft = drafts[i]
|
||||
if (draft.deleted) continue
|
||||
if (draft.originalName == null && draft.name && draft.url) {
|
||||
try {
|
||||
await gitAddRemote(folderPath, draft.name, draft.url)
|
||||
} catch (err) {
|
||||
newErrors[i] = extractError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Process URL modifications
|
||||
for (let i = 0; i < drafts.length; i++) {
|
||||
const draft = drafts[i]
|
||||
if (draft.deleted || draft.originalName == null) continue
|
||||
if (draft.url !== draft.originalUrl) {
|
||||
try {
|
||||
await gitSetRemoteUrl(folderPath, draft.originalName, draft.url)
|
||||
} catch (err) {
|
||||
newErrors[i] = extractError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fetch all surviving remotes
|
||||
for (let i = 0; i < drafts.length; i++) {
|
||||
const draft = drafts[i]
|
||||
if (draft.deleted || newErrors[i]) continue
|
||||
const remoteName = draft.originalName ?? draft.name
|
||||
if (!remoteName) continue
|
||||
try {
|
||||
await gitFetchRemote(folderPath, remoteName)
|
||||
} catch (err) {
|
||||
newErrors[i] = extractError(err)
|
||||
}
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors)
|
||||
} else {
|
||||
onSaved()
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save remotes:", err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleDrafts = drafts.filter((d) => !d.deleted)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("manageRemotesTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-72">
|
||||
<div className="space-y-2 pr-2">
|
||||
{loadingRemotes ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
...
|
||||
</p>
|
||||
) : visibleDrafts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
{t("manageRemotesEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
drafts.map(
|
||||
(draft, index) =>
|
||||
!draft.deleted && (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder={t("remoteNamePlaceholder")}
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
updateDraft(index, "name", e.target.value)
|
||||
}
|
||||
disabled={draft.originalName != null}
|
||||
className={`h-8 text-sm w-32 shrink-0 ${errors[index] ? "border-destructive" : ""}`}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("remoteUrlPlaceholder")}
|
||||
value={draft.url}
|
||||
onChange={(e) =>
|
||||
updateDraft(index, "url", e.target.value)
|
||||
}
|
||||
className={`h-8 text-sm font-mono flex-1 ${errors[index] ? "border-destructive" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => removeDraft(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{errors[index] && (
|
||||
<p className="text-xs text-destructive pl-1 truncate">
|
||||
{errors[index]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
<Button variant="outline" size="sm" onClick={addDraft}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t("addRemote")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("savingRemotes") : tCommon("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user