设置界面支持版本控制和github账号管理

This commit is contained in:
itpkcn@gmail.com
2026-03-21 11:33:48 +08:00
parent e801f041a0
commit 62fab2c3f2
22 changed files with 1595 additions and 4 deletions

View File

@@ -0,0 +1,179 @@
"use client"
import { useCallback, useState } from "react"
import { Eye, EyeOff, Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { validateGitHubToken } from "@/lib/tauri"
import type { GitHubAccount } from "@/lib/types"
interface AddGitHubAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onAccountAdded: (account: GitHubAccount) => void
isFirstAccount: boolean
}
export function AddGitHubAccountDialog({
open,
onOpenChange,
onAccountAdded,
isFirstAccount,
}: AddGitHubAccountDialogProps) {
const t = useTranslations("VersionControlSettings")
const [serverUrl, setServerUrl] = useState("https://github.com")
const [token, setToken] = useState("")
const [showToken, setShowToken] = useState(false)
const [validating, setValidating] = useState(false)
const [error, setError] = useState<string | null>(null)
const resetForm = useCallback(() => {
setServerUrl("https://github.com")
setToken("")
setShowToken(false)
setValidating(false)
setError(null)
}, [])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
resetForm()
}
onOpenChange(nextOpen)
},
[onOpenChange, resetForm]
)
const handleSubmit = useCallback(async () => {
const trimmedToken = token.trim()
if (!trimmedToken) {
setError(t("addFailed", { message: "Token is required" }))
return
}
setValidating(true)
setError(null)
try {
const result = await validateGitHubToken(serverUrl.trim(), trimmedToken)
if (!result.success) {
setError(
t("addFailed", { message: result.message ?? "Validation failed" })
)
return
}
const account: GitHubAccount = {
id: crypto.randomUUID(),
server_url: serverUrl.trim() || "https://github.com",
username: result.username ?? "unknown",
token: trimmedToken,
scopes: result.scopes,
avatar_url: result.avatar_url,
is_default: isFirstAccount,
created_at: new Date().toISOString(),
}
onAccountAdded(account)
handleOpenChange(false)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(t("addFailed", { message }))
} finally {
setValidating(false)
}
}, [serverUrl, token, isFirstAccount, onAccountAdded, handleOpenChange, t])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("addAccount")}</DialogTitle>
<DialogDescription>{t("githubDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("serverUrl")}
</label>
<Input
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder={t("serverUrlPlaceholder")}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
{t("token")}
</label>
<div className="relative">
<Input
type={showToken ? "text" : "password"}
value={token}
onChange={(e) => {
setToken(e.target.value)
setError(null)
}}
placeholder={t("tokenPlaceholder")}
className="pr-9"
/>
<Button
type="button"
variant="ghost"
size="xs"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
onClick={() => setShowToken(!showToken)}
tabIndex={-1}
>
{showToken ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
{t("tokenHint")}
</p>
</div>
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}
</div>
)}
</div>
<DialogFooter>
<Button
onClick={handleSubmit}
disabled={validating || !token.trim()}
>
{validating ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("validating")}
</>
) : (
t("validateAndAdd")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -4,6 +4,7 @@ import { useCallback, type ComponentType, type ReactNode } from "react"
import {
Bot,
BookOpenText,
GitBranch,
Keyboard,
Palette,
PlugZap,
@@ -19,7 +20,14 @@ import { AppTitleBar } from "@/components/layout/app-title-bar"
interface SettingsNavItem {
href: string
labelKey: "appearance" | "agents" | "mcp" | "skills" | "shortcuts" | "system"
labelKey:
| "appearance"
| "agents"
| "mcp"
| "skills"
| "shortcuts"
| "version_control"
| "system"
icon: ComponentType<{ className?: string }>
}
@@ -49,6 +57,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
labelKey: "shortcuts",
icon: Keyboard,
},
{
href: "/settings/version-control",
labelKey: "version_control",
icon: GitBranch,
},
{
href: "/settings/system",
labelKey: "system",

View File

@@ -0,0 +1,477 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
CheckCircle2,
GitBranch,
Github,
Loader2,
Save,
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,
} from "@/lib/tauri"
import type {
GitDetectResult,
GitHubAccount,
GitHubAccountsSettings,
} from "@/lib/types"
import { AddGitHubAccountDialog } from "./add-github-account-dialog"
export function VersionControlSettings() {
const t = useTranslations("VersionControlSettings")
const [loading, setLoading] = useState(true)
const [gitInfo, setGitInfo] = useState<GitDetectResult | null>(null)
const [customPath, setCustomPath] = useState("")
const [savingGit, setSavingGit] = useState(false)
const [testingGit, setTestingGit] = useState(false)
const [testResult, setTestResult] = useState<GitDetectResult | null>(null)
const [accounts, setAccounts] = useState<GitHubAccountsSettings>({
accounts: [],
})
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [testingAccountId, setTestingAccountId] = useState<string | null>(null)
const [removeTarget, setRemoveTarget] = useState<GitHubAccount | null>(null)
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])
const handleTestGit = useCallback(async () => {
const pathToTest = customPath.trim() || "git"
setTestingGit(true)
setTestResult(null)
try {
const result = await testGitPath(pathToTest)
setTestResult(result)
if (result.installed) {
toast.success(t("testSuccess"))
} else {
toast.error(t("testFailed", { message: "not a valid git executable" }))
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("testFailed", { message }))
} finally {
setTestingGit(false)
}
}, [customPath, t])
const handleSaveGit = useCallback(async () => {
setSavingGit(true)
try {
await updateGitSettings({
custom_path: customPath.trim() || null,
})
const git = await detectGit()
setGitInfo(git)
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 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 result = await validateGitHubToken(
account.server_url,
account.token
)
if (result.success) {
toast.success(t("connectionSuccess"))
} else {
toast.error(
t("connectionFailed", {
message: result.message ?? "Unknown error",
})
)
}
} 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 {
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])
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>
<p className="text-xs text-muted-foreground leading-5">
{t("gitDescription")}
</p>
{/* Git status */}
<div className="rounded-md border bg-muted/20 px-3 py-3 text-xs space-y-2">
<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>
</>
)}
</div>
{gitInfo?.version && (
<p className="text-muted-foreground">
{t("gitVersion")}: {gitInfo.version}
</p>
)}
{gitInfo?.path && (
<p className="text-muted-foreground">
{t("gitPath")}: {gitInfo.path}
</p>
)}
</div>
{/* Custom path */}
<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)
setTestResult(null)
}}
placeholder={t("customGitPathPlaceholder")}
className="flex-1"
/>
<Button
size="sm"
variant="outline"
onClick={handleTestGit}
disabled={testingGit}
>
{testingGit ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("testing")}
</>
) : (
t("test")
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
{t("customGitPathHint")}
</p>
{testResult && (
<div
className={`flex items-center gap-1.5 text-xs ${
testResult.installed
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}`}
>
{testResult.installed ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{testResult.installed
? `${t("testSuccess")} (${testResult.version})`
: t("testFailed", { message: "invalid" })}
</div>
)}
</div>
<div className="flex justify-end">
<Button size="sm" onClick={handleSaveGit} disabled={savingGit}>
{savingGit ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("save")}
</>
)}
</Button>
</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>
{/* Account list */}
{accounts.accounts.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">
{accounts.accounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-3 rounded-lg border bg-muted/10 px-3 py-2.5"
>
{/* Avatar */}
{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>
)}
{/* Info */}
<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>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="xs"
variant="ghost"
onClick={() => handleTestConnection(account)}
disabled={testingAccountId === account.id}
>
{testingAccountId === account.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
t("testConnection")
)}
</Button>
{!account.is_default && (
<Button
size="xs"
variant="ghost"
onClick={() => handleSetDefault(account.id)}
>
{t("setDefault")}
</Button>
)}
<Button
size="xs"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setRemoveTarget(account)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
{t("addAccount")}
</Button>
</div>
</section>
</div>
{/* Add Account Dialog */}
<AddGitHubAccountDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
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>
)
}