286 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|