gitdataai/src/app/repository/settings/general.tsx
2026-04-15 09:08:09 +08:00

286 lines
11 KiB
TypeScript

import { gitBranchList, gitUpdateRepo, projectExchangeName, projectExchangeVisibility, projectExchangeTitle } from "@/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Edit2,
GitBranch,
GitCommit,
Globe,
Loader2,
Lock,
} from "lucide-react";
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { useRepo } from "@/contexts";
import {getApiErrorMessage} from '@/lib/api-error';
export function RepoSettingsGeneral() {
const repo = useRepo();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { namespace, repoName } = useParams<{ namespace: string; repoName: string }>();
const ns = namespace!;
const rn = repoName!;
// Project name
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState(repo?.repo_name ?? "");
// Visibility
const visibilityMutation = useMutation({
mutationFn: async (isPrivate: boolean) => {
await projectExchangeVisibility({
path: { project_name: ns },
body: { is_public: !isPrivate },
});
},
onSuccess: () => {
toast.success("Visibility updated");
queryClient.invalidateQueries({ queryKey: ["projectRepos", ns] });
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to update visibility"));
},
});
// Default branch
const [editingBranch, setEditingBranch] = useState(false);
const [selectedBranch, setSelectedBranch] = useState(repo?.default_branch ?? "");
const { data: branchesData } = useQuery({
queryKey: ["repo-branches", ns, rn],
queryFn: async () => {
const resp = await gitBranchList({ path: { namespace: ns, repo: rn } });
return resp.data?.data ?? [];
},
enabled: !!ns && !!rn && editingBranch,
staleTime: 30 * 1000,
});
// Description
const [descText, setDescText] = useState(repo?.description ?? "");
const [descSaved, setDescSaved] = useState(true);
useEffect(() => {
if (repo) {
setDescText(repo.description ?? "");
setDescSaved(true);
}
}, [repo?.description]);
useEffect(() => {
if (repo) setSelectedBranch(repo.default_branch ?? "");
}, [repo?.default_branch]);
const nameMutation = useMutation({
mutationFn: async () => {
if (!newName.trim() || newName === repo?.repo_name) return;
await projectExchangeName({
path: { project_name: ns },
body: { name: newName.trim() },
});
},
onSuccess: () => {
toast.success("Repository renamed");
queryClient.invalidateQueries({ queryKey: ["projectRepos", ns] });
navigate(`/repository/${newName}/${rn}/settings`);
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to rename repository"));
},
});
const branchMutation = useMutation({
mutationFn: async () => {
if (!selectedBranch || selectedBranch === repo?.default_branch) return;
await gitUpdateRepo({
path: { namespace: ns, repo: rn },
body: { default_branch: selectedBranch },
});
},
onSuccess: () => {
toast.success("Default branch updated");
queryClient.invalidateQueries({ queryKey: ["projectRepos", ns] });
setEditingBranch(false);
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to update default branch"));
},
});
const descMutation = useMutation({
mutationFn: async () => {
await projectExchangeTitle({
path: { project_name: ns },
body: { description: descText.trim() || null },
});
},
onSuccess: () => {
toast.success("Description updated");
queryClient.invalidateQueries({ queryKey: ["projectRepos", ns] });
setDescSaved(true);
},
onError: (err: unknown) => {
toast.error(getApiErrorMessage(err, "Failed to update description"));
},
});
const branches: Array<{ name: string }> = branchesData ?? [];
if (!repo) return null;
return (
<div className="space-y-4">
{/* Repository Info */}
<div className="border rounded-lg bg-card">
<div className="p-4 border-b">
<h2 className="text-sm font-semibold">Repository Info</h2>
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-6">
{/* Name */}
<div>
<Label className="text-muted-foreground text-xs uppercase tracking-wide">Repository Name</Label>
{editingName ? (
<div className="flex items-center gap-2 mt-0.5">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-8 font-mono text-sm"
autoFocus
/>
<Button size="sm" onClick={() => nameMutation.mutate()} disabled={nameMutation.isPending || !newName.trim()}>
{nameMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
</Button>
<Button size="sm" variant="outline" onClick={() => { setEditingName(false); setNewName(repo.repo_name); }}>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-2 mt-0.5">
<p className="font-medium font-mono">{repo.repo_name}</p>
<button
onClick={() => { setEditingName(true); setNewName(repo.repo_name); }}
className="p-1 hover:bg-muted rounded transition-colors"
>
<Edit2 className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
)}
</div>
{/* Namespace */}
<div>
<Label className="text-muted-foreground text-xs uppercase tracking-wide">Namespace</Label>
<p className="font-medium mt-0.5">{repo.namespace}</p>
</div>
{/* Default Branch */}
<div>
<Label className="text-muted-foreground text-xs uppercase tracking-wide">Default Branch</Label>
{editingBranch ? (
<div className="flex items-center gap-2 mt-0.5">
<select
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
className="h-8 px-2 text-sm border rounded bg-background"
>
{branches.map((b) => (
<option key={b.name} value={b.name}>{b.name}</option>
))}
</select>
<Button
size="sm"
onClick={() => branchMutation.mutate()}
disabled={branchMutation.isPending || selectedBranch === repo.default_branch}
>
{branchMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Save"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => { setEditingBranch(false); setSelectedBranch(repo.default_branch); }}
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-2 mt-0.5">
<p className="font-medium flex items-center gap-1.5">
<GitBranch className="h-4 w-4 text-muted-foreground" />
{repo.default_branch}
</p>
<button
onClick={() => { setEditingBranch(true); setSelectedBranch(repo.default_branch); }}
className="p-1 hover:bg-muted rounded transition-colors"
>
<Edit2 className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
)}
</div>
{/* Visibility */}
<div>
<Label className="text-muted-foreground text-xs uppercase tracking-wide">Visibility</Label>
<div className="flex items-center gap-3 mt-0.5">
<button
onClick={() => visibilityMutation.mutate(repo.is_private)}
disabled={visibilityMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-sm font-medium border-border bg-muted/50 text-foreground hover:bg-muted transition-colors"
>
{repo.is_private ? <Lock className="h-4 w-4" /> : <Globe className="h-4 w-4" />}
{repo.is_private ? "Private" : "Public"}
</button>
{visibilityMutation.isPending && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
</div>
{/* Stats */}
<div>
<Label className="text-muted-foreground text-xs uppercase tracking-wide">Commits</Label>
<p className="font-medium mt-0.5 flex items-center gap-1.5">
<GitCommit className="h-4 w-4 text-muted-foreground" />
{(repo.commit_count ?? 0).toLocaleString()}
</p>
</div>
<div>
<Label className="text-muted-foreground text-xs uppercase tracking-wide">Branches</Label>
<p className="font-medium mt-0.5">{(repo as any).branch_count ?? 0}</p>
</div>
</div>
</div>
</div>
{/* Description */}
<div className="border rounded-lg bg-card">
<div className="p-4 border-b flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold">Description</h2>
<p className="text-xs text-muted-foreground mt-0.5">Project description shown on the overview page</p>
</div>
{!descSaved && (
<Button size="sm" onClick={() => descMutation.mutate()} disabled={descMutation.isPending}>
{descMutation.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
Save
</Button>
)}
</div>
<div className="p-4">
<Textarea
value={descText}
onChange={(e) => { setDescText(e.target.value); setDescSaved(false); }}
placeholder="Add a description..."
rows={3}
className="resize-none"
/>
</div>
</div>
</div>
);
}