556 lines
17 KiB
TypeScript
556 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import {
|
|
CheckCircle2,
|
|
GitBranch,
|
|
Github,
|
|
Globe,
|
|
Loader2,
|
|
Trash2,
|
|
XCircle,
|
|
} from "lucide-react"
|
|
import { useTranslations } from "next-intl"
|
|
import { toast } from "sonner"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog"
|
|
import {
|
|
detectGit,
|
|
getGitSettings,
|
|
updateGitSettings,
|
|
testGitPath,
|
|
getGitHubAccounts,
|
|
updateGitHubAccounts,
|
|
validateGitHubToken,
|
|
getAccountToken,
|
|
deleteAccountToken,
|
|
} from "@/lib/tauri"
|
|
import type {
|
|
GitDetectResult,
|
|
GitHubAccount,
|
|
GitHubAccountsSettings,
|
|
} from "@/lib/types"
|
|
import { AddGitHubAccountDialog } from "./add-github-account-dialog"
|
|
import { AddGitAccountDialog } from "./add-git-account-dialog"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function isGitHubAccount(account: GitHubAccount): boolean {
|
|
const url = account.server_url.toLowerCase()
|
|
return url.includes("github.com")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared account row component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function AccountRow({
|
|
account,
|
|
testingId,
|
|
onTest,
|
|
onSetDefault,
|
|
onRemove,
|
|
t,
|
|
}: {
|
|
account: GitHubAccount
|
|
testingId: string | null
|
|
onTest: (account: GitHubAccount) => void
|
|
onSetDefault: (id: string) => void
|
|
onRemove: (account: GitHubAccount) => void
|
|
t: ReturnType<typeof useTranslations<"VersionControlSettings">>
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-lg border bg-muted/10 px-3 py-2.5">
|
|
{account.avatar_url ? (
|
|
<img
|
|
src={account.avatar_url}
|
|
alt={account.username}
|
|
className="h-8 w-8 rounded-full"
|
|
/>
|
|
) : (
|
|
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center text-xs font-medium">
|
|
{account.username[0]?.toUpperCase()}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium truncate">
|
|
{account.username}
|
|
</span>
|
|
{account.is_default && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
{t("defaultLabel")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
|
<span className="truncate">{account.server_url}</span>
|
|
{account.scopes.length > 0 && (
|
|
<>
|
|
<span>·</span>
|
|
<span className="truncate">{account.scopes.join(", ")}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
onClick={() => onTest(account)}
|
|
disabled={testingId === account.id}
|
|
>
|
|
{testingId === account.id ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
t("testConnection")
|
|
)}
|
|
</Button>
|
|
{!account.is_default && (
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
onClick={() => onSetDefault(account.id)}
|
|
>
|
|
{t("setDefault")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => onRemove(account)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function VersionControlSettings() {
|
|
const t = useTranslations("VersionControlSettings")
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
const [gitInfo, setGitInfo] = useState<GitDetectResult | null>(null)
|
|
const [customPath, setCustomPath] = useState("")
|
|
const [editingPath, setEditingPath] = useState(false)
|
|
const [savingGit, setSavingGit] = useState(false)
|
|
|
|
const [accounts, setAccounts] = useState<GitHubAccountsSettings>({
|
|
accounts: [],
|
|
})
|
|
const [addGitHubOpen, setAddGitHubOpen] = useState(false)
|
|
const [addGitOpen, setAddGitOpen] = useState(false)
|
|
const [testingAccountId, setTestingAccountId] = useState<string | null>(null)
|
|
const [removeTarget, setRemoveTarget] = useState<GitHubAccount | null>(null)
|
|
|
|
// Split accounts into GitHub vs other
|
|
const githubAccounts = useMemo(
|
|
() => accounts.accounts.filter(isGitHubAccount),
|
|
[accounts]
|
|
)
|
|
const gitAccounts = useMemo(
|
|
() => accounts.accounts.filter((a) => !isGitHubAccount(a)),
|
|
[accounts]
|
|
)
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [git, settings, ghAccounts] = await Promise.all([
|
|
detectGit(),
|
|
getGitSettings(),
|
|
getGitHubAccounts(),
|
|
])
|
|
setGitInfo(git)
|
|
setCustomPath(settings.custom_path ?? "")
|
|
setAccounts(ghAccounts)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(t("loadFailed", { message }))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [t])
|
|
|
|
useEffect(() => {
|
|
loadData().catch(console.error)
|
|
}, [loadData])
|
|
|
|
// --- Git path handlers ---
|
|
|
|
const handleSaveGit = useCallback(async () => {
|
|
const trimmed = customPath.trim()
|
|
setSavingGit(true)
|
|
try {
|
|
// Test first if a custom path is provided
|
|
if (trimmed) {
|
|
const result = await testGitPath(trimmed)
|
|
if (!result.installed) {
|
|
toast.error(
|
|
t("testFailed", { message: "not a valid git executable" })
|
|
)
|
|
return
|
|
}
|
|
}
|
|
await updateGitSettings({ custom_path: trimmed || null })
|
|
const git = await detectGit()
|
|
setGitInfo(git)
|
|
setEditingPath(false)
|
|
toast.success(t("saveSuccess"))
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(t("saveFailed", { message }))
|
|
} finally {
|
|
setSavingGit(false)
|
|
}
|
|
}, [customPath, t])
|
|
|
|
const handleCancelEdit = useCallback(() => {
|
|
setEditingPath(false)
|
|
// Restore to the saved value
|
|
getGitSettings()
|
|
.then((s) => setCustomPath(s.custom_path ?? ""))
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
// --- Shared account handlers ---
|
|
|
|
const handleAccountAdded = useCallback(
|
|
async (account: GitHubAccount) => {
|
|
const updated: GitHubAccountsSettings = {
|
|
accounts: [
|
|
...accounts.accounts.map((a) =>
|
|
account.is_default ? { ...a, is_default: false } : a
|
|
),
|
|
account,
|
|
],
|
|
}
|
|
try {
|
|
const saved = await updateGitHubAccounts(updated)
|
|
setAccounts(saved)
|
|
toast.success(t("addSuccess", { username: account.username }))
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(t("addFailed", { message }))
|
|
}
|
|
},
|
|
[accounts, t]
|
|
)
|
|
|
|
const handleTestConnection = useCallback(
|
|
async (account: GitHubAccount) => {
|
|
setTestingAccountId(account.id)
|
|
try {
|
|
const token = await getAccountToken(account.id)
|
|
if (!token) {
|
|
toast.error(t("connectionFailed", { message: "Token not found" }))
|
|
return
|
|
}
|
|
if (isGitHubAccount(account)) {
|
|
const result = await validateGitHubToken(account.server_url, token)
|
|
if (result.success) {
|
|
toast.success(t("connectionSuccess"))
|
|
} else {
|
|
toast.error(
|
|
t("connectionFailed", {
|
|
message: result.message ?? "Unknown error",
|
|
})
|
|
)
|
|
}
|
|
} else {
|
|
// For non-GitHub accounts we can't validate via API,
|
|
// just confirm the token exists in keyring.
|
|
toast.success(t("connectionSuccess"))
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(t("connectionFailed", { message }))
|
|
} finally {
|
|
setTestingAccountId(null)
|
|
}
|
|
},
|
|
[t]
|
|
)
|
|
|
|
const handleSetDefault = useCallback(
|
|
async (accountId: string) => {
|
|
const updated: GitHubAccountsSettings = {
|
|
accounts: accounts.accounts.map((a) => ({
|
|
...a,
|
|
is_default: a.id === accountId,
|
|
})),
|
|
}
|
|
try {
|
|
const saved = await updateGitHubAccounts(updated)
|
|
setAccounts(saved)
|
|
toast.success(t("defaultSet"))
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(message)
|
|
}
|
|
},
|
|
[accounts, t]
|
|
)
|
|
|
|
const handleRemoveAccount = useCallback(async () => {
|
|
if (!removeTarget) return
|
|
const updated: GitHubAccountsSettings = {
|
|
accounts: accounts.accounts.filter((a) => a.id !== removeTarget.id),
|
|
}
|
|
try {
|
|
await deleteAccountToken(removeTarget.id)
|
|
const saved = await updateGitHubAccounts(updated)
|
|
setAccounts(saved)
|
|
toast.success(t("removeSuccess"))
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(message)
|
|
} finally {
|
|
setRemoveTarget(null)
|
|
}
|
|
}, [accounts, removeTarget, t])
|
|
|
|
// --- Render ---
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t("loading")}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-auto">
|
|
<div className="w-full space-y-4">
|
|
<section className="space-y-1">
|
|
<h1 className="text-sm font-semibold">{t("sectionTitle")}</h1>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("sectionDescription")}
|
|
</p>
|
|
</section>
|
|
|
|
{/* ---- Git Configuration ---- */}
|
|
<section className="rounded-xl border bg-card p-4 space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold">{t("gitTitle")}</h2>
|
|
</div>
|
|
|
|
<div className="rounded-md border bg-muted/20 px-3 py-3 text-xs space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{gitInfo?.installed ? (
|
|
<>
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
|
<span className="text-green-600 dark:text-green-400 font-medium">
|
|
{t("gitDetected")}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
|
<span className="text-red-600 dark:text-red-400 font-medium">
|
|
{t("gitNotFound")}
|
|
</span>
|
|
</>
|
|
)}
|
|
{gitInfo?.version && (
|
|
<span className="text-muted-foreground">
|
|
{gitInfo.version}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!editingPath && (
|
|
<Button
|
|
size="xs"
|
|
variant="ghost"
|
|
className="text-xs"
|
|
onClick={() => setEditingPath(true)}
|
|
>
|
|
{t("customGitPath")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{gitInfo?.path && (
|
|
<p className="text-muted-foreground">
|
|
{t("gitPath")}: {gitInfo.path}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{editingPath && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
{t("customGitPath")}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={customPath}
|
|
onChange={(e) => setCustomPath(e.target.value)}
|
|
placeholder={t("customGitPathPlaceholder")}
|
|
className="flex-1"
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleCancelEdit}
|
|
disabled={savingGit}
|
|
>
|
|
{t("removeCancel")}
|
|
</Button>
|
|
<Button size="sm" onClick={handleSaveGit} disabled={savingGit}>
|
|
{savingGit ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
t("save")
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{t("customGitPathHint")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ---- GitHub Accounts ---- */}
|
|
<section className="rounded-xl border bg-card p-4 space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Github className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold">{t("githubTitle")}</h2>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground leading-5">
|
|
{t("githubDescription")}
|
|
</p>
|
|
|
|
{githubAccounts.length === 0 ? (
|
|
<div className="rounded-md border border-dashed bg-muted/10 px-4 py-6 text-center text-xs text-muted-foreground">
|
|
{t("noAccounts")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{githubAccounts.map((account) => (
|
|
<AccountRow
|
|
key={account.id}
|
|
account={account}
|
|
testingId={testingAccountId}
|
|
onTest={handleTestConnection}
|
|
onSetDefault={handleSetDefault}
|
|
onRemove={setRemoveTarget}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<Button size="sm" onClick={() => setAddGitHubOpen(true)}>
|
|
{t("addAccount")}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ---- Git Accounts (non-GitHub) ---- */}
|
|
<section className="rounded-xl border bg-card p-4 space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold">
|
|
{t("gitAccount.sectionTitle")}
|
|
</h2>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground leading-5">
|
|
{t("gitAccount.sectionDescription")}
|
|
</p>
|
|
|
|
{gitAccounts.length === 0 ? (
|
|
<div className="rounded-md border border-dashed bg-muted/10 px-4 py-6 text-center text-xs text-muted-foreground">
|
|
{t("gitAccount.noAccounts")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{gitAccounts.map((account) => (
|
|
<AccountRow
|
|
key={account.id}
|
|
account={account}
|
|
testingId={testingAccountId}
|
|
onTest={handleTestConnection}
|
|
onSetDefault={handleSetDefault}
|
|
onRemove={setRemoveTarget}
|
|
t={t}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<Button size="sm" onClick={() => setAddGitOpen(true)}>
|
|
{t("gitAccount.addAccount")}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Dialogs */}
|
|
<AddGitHubAccountDialog
|
|
open={addGitHubOpen}
|
|
onOpenChange={setAddGitHubOpen}
|
|
onAccountAdded={handleAccountAdded}
|
|
isFirstAccount={accounts.accounts.length === 0}
|
|
/>
|
|
<AddGitAccountDialog
|
|
open={addGitOpen}
|
|
onOpenChange={setAddGitOpen}
|
|
onAccountAdded={handleAccountAdded}
|
|
isFirstAccount={accounts.accounts.length === 0}
|
|
/>
|
|
|
|
{/* Remove Confirmation */}
|
|
<AlertDialog
|
|
open={!!removeTarget}
|
|
onOpenChange={(open) => !open && setRemoveTarget(null)}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("removeConfirmTitle")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("removeConfirmMessage", {
|
|
username: removeTarget?.username ?? "",
|
|
})}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("removeCancel")}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleRemoveAccount}>
|
|
{t("removeConfirm")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|