253 lines
7.7 KiB
TypeScript
253 lines
7.7 KiB
TypeScript
"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/api"
|
|
|
|
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>
|
|
)
|
|
}
|