From b882c870c35e2893cf64f4b0136493a568ea2762 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 13:24:08 +0200 Subject: [PATCH] feat(ui): Repositories im Git-Panel verwalten Eine Leiste mit allen Repositories des Projekts, sobald es mehr als eines gibt - bei einem einzigen waere eine Leiste mit genau einem Knopf nur Ballast. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich. Verknuepfen und Loesen laufen ueber einen eigenen Dialog mit derselben Repo-Auswahl wie beim Projekt. Der Projektdialog bearbeitet weiterhin nur das fuehrende: eine Liste darin wuerde den Dialog sprengen, in dem es eigentlich um das Projekt geht. Eine frei waehlbare Bezeichnung tritt an die Stelle von owner/name. Die Karte zeigt "+2 Repos" und im Titel, welche es sind. repoTitle steht in lib/git/meta statt bei den uebrigen Repository-Helfern: lib/repositories zieht Prisma und damit node:-Module nach, die in einer Client-Komponente nicht landen duerfen. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/projects/[id]/page.tsx | 5 +- src/components/ConnectionsManager.tsx | 8 +- src/components/GitPanel.tsx | 188 ++++++++++++--- src/components/KanbanBoard.tsx | 7 +- src/components/ProjectBoard.tsx | 4 +- src/components/ProjectCard.tsx | 31 ++- src/components/ProjectDialog.tsx | 13 +- src/components/ProjectHeader.tsx | 35 ++- src/components/RepositoryDialog.tsx | 334 ++++++++++++++++++++++++++ 9 files changed, 558 insertions(+), 67 deletions(-) create mode 100644 src/components/RepositoryDialog.tsx diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 043f60b..3fdbfc6 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -87,7 +87,10 @@ export default async function ProjectPage({ params }: PageProps) {
- +
diff --git a/src/components/ConnectionsManager.tsx b/src/components/ConnectionsManager.tsx index 19cb374..3cc6518 100644 --- a/src/components/ConnectionsManager.tsx +++ b/src/components/ConnectionsManager.tsx @@ -140,8 +140,8 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit } async function remove(connection: ConnectionWithCount) { - const linked = connection._count?.projects ?? 0; - const hint = linked > 0 ? `\n\n${linked} Projekt(e) verlieren die Verknuepfung.` : ""; + const linked = connection._count?.repositories ?? 0; + const hint = linked > 0 ? `\n\n${linked} Repository(s) verlieren die Verknuepfung.` : ""; if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return; setBusyId(connection.id); await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined); @@ -257,9 +257,9 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit ) : null}
-
Projekte
+
Repositories
- {connection._count?.projects ?? 0} + {connection._count?.repositories ?? 0}
{connection.lastCheckedAt ? ( diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 8b9f782..0a7caf1 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -16,19 +16,25 @@ import { Loader2, Lock, Package, + Plus, RefreshCw, ShieldCheck, Star, Tag, + Unlink, } from "lucide-react"; import { CiBadge } from "@/components/CiBadge"; +import { RepositoryDialog } from "@/components/RepositoryDialog"; import { Sparkline } from "@/components/Sparkline"; +import { repoTitle } from "@/lib/git/meta"; import { asArray, asRecord } from "@/types"; import type { BranchInfo, CommitInfo, + ConnectionSafe, ProjectDetail, ReleaseInfo, + RepositoryWithSnapshot, TagInfo, TicketInfo, } from "@/types"; @@ -45,22 +51,37 @@ const TABS = [ type TabKey = (typeof TABS)[number]["key"]; -export function GitPanel({ project }: { project: ProjectDetail }) { +export function GitPanel({ + project, + connections, +}: { + project: ProjectDetail; + connections: ConnectionSafe[]; +}) { const router = useRouter(); const [tab, setTab] = useState("pulls"); const [syncing, setSyncing] = useState(false); const [importing, setImporting] = useState(false); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [addOpen, setAddOpen] = useState(false); + + const repositories = project.repositories; + const [selectedId, setSelectedId] = useState(repositories[0]?.id ?? null); + + // Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende + // zurueckfallen, statt eine leere Ansicht zu zeigen. + const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null; /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ async function importIssues() { + if (!repository) return; setImporting(true); setMessage(null); try { const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: "{}", + body: JSON.stringify({ repositoryId: repository.id }), }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen."); @@ -79,7 +100,7 @@ export function GitPanel({ project }: { project: ProjectDetail }) { } } - const snapshot = project.snapshot; + const snapshot = repository?.snapshot ?? null; const branches = asArray(snapshot?.branches); const tags = asArray(snapshot?.tags); const commits = asArray(snapshot?.commits); @@ -89,13 +110,15 @@ export function GitPanel({ project }: { project: ProjectDetail }) { const issues = asArray(snapshot?.issues); const activity = asArray(snapshot?.commitActivity); - const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl); - - async function sync() { + /** Ein einzelnes Repository oder – ohne Argument – alle. */ + async function sync(nurDieses?: string) { setSyncing(true); setMessage(null); try { - const response = await fetch(`/api/projects/${project.id}/sync`, { method: "POST" }); + const url = nurDieses + ? `/api/repositories/${nurDieses}/sync` + : `/api/projects/${project.id}/sync`; + const response = await fetch(url, { method: "POST" }); const payload = await response.json().catch(() => ({})); setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." }); router.refresh(); @@ -109,16 +132,41 @@ export function GitPanel({ project }: { project: ProjectDetail }) { } } - if (!hasRepo) { + async function removeRepository(entry: RepositoryWithSnapshot) { + if ( + !window.confirm( + `„${repoTitle(entry)}“ vom Projekt loesen? Der geholte Stand geht dabei verloren.`, + ) + ) { + return; + } + await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined); + setSelectedId(null); + router.refresh(); + } + + if (!repository) { return ( -
- -

Kein Repository verknuepft

-

- Hinterlege im Projekt eine Repository-URL und waehle eine Git-Verbindung, um - Branches, Tags, Commits und Releases hier zu sehen. -

-
+ <> +
+ +

Kein Repository verknuepft

+

+ Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen. + Ein Projekt darf auch mehrere haben – etwa Frontend und Backend. +

+ +
+ setAddOpen(false)} + projectId={project.id} + connections={connections} + /> + ); } @@ -129,12 +177,53 @@ export function GitPanel({ project }: { project: ProjectDetail }) { return (
+ {/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine + Leiste mit genau einem Knopf nur Ballast. */} + {repositories.length > 1 ? ( +
+ {repositories.map((entry) => { + const aktiv = entry.id === repository.id; + return ( + + ); + })} + +
+ ) : null} + {/* Kopf mit Sync */}

- {snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`} + {snapshot?.fullName ?? repoTitle(repository)}

{snapshot?.visibility ? ( @@ -160,22 +249,67 @@ export function GitPanel({ project }: { project: ProjectDetail }) {

- {project.lastSyncedAt - ? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}` + {repository.lastSyncedAt + ? `Zuletzt synchronisiert ${formatRelative(repository.lastSyncedAt)} · ${formatDateTime(repository.lastSyncedAt)}` : "Noch nie synchronisiert."}

- ) : ( - + )} - Jetzt synchronisieren - + + +
+ setAddOpen(false)} + projectId={project.id} + connections={connections} + /> + {message ? (

) : null} - {project.syncError && !message ? ( + {repository.syncError && !message ? (

- {project.syncError} + {repository.syncError}

) : null} diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx index 020910b..f5c94d9 100644 --- a/src/components/KanbanBoard.tsx +++ b/src/components/KanbanBoard.tsx @@ -256,11 +256,12 @@ function KanbanCard({
- - {project.snapshot?.defaultBranch ? ( + {/* Auf der schmalen Kanban-Karte nur das fuehrende Repository. */} + + {project.repositories[0]?.snapshot?.defaultBranch ? ( - {project.snapshot.defaultBranch} + {project.repositories[0].snapshot.defaultBranch} ) : null} {project._count.notes > 0 ? ( diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx index 74d4c48..4ce84e4 100644 --- a/src/components/ProjectBoard.tsx +++ b/src/components/ProjectBoard.tsx @@ -140,7 +140,9 @@ export function ProjectBoard({ (project.summary ?? "").toLowerCase().includes(needle) || (project.description ?? "").toLowerCase().includes(needle) || project.tags.some((tag) => tag.includes(needle)) || - (project.snapshot?.fullName ?? "").toLowerCase().includes(needle) || + project.repositories.some((repo) => + (repo.snapshot?.fullName ?? "").toLowerCase().includes(needle), + ) || // Treffer in Notizen kommen vom Server dazu. noteHitProjectIds.has(project.id) ); diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index 9f199e7..1e24545 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -14,6 +14,7 @@ import { import { CiBadge } from "@/components/CiBadge"; import { Sparkline } from "@/components/Sparkline"; import { StatusSelect } from "@/components/StatusSelect"; +import { repoTitle } from "@/lib/git/meta"; import { PRIORITY_META, accentOf } from "@/lib/status"; import { cn, formatRelative, truncate } from "@/lib/utils"; import { asArray, type ProjectListItem } from "@/types"; @@ -31,7 +32,14 @@ const PROVIDER_LABEL: Record = { export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) { const accent = accentOf(project.accent); const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; - const snapshot = project.snapshot; + // Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es + // insgesamt sind, sagt die Zahl daneben. + const leading = project.repositories[0] ?? null; + const snapshot = leading?.snapshot ?? null; + const weitere = project.repositories.length - 1; + // Ein Fehler an irgendeinem Repo gehoert auf die Karte, nicht nur einer am + // fuehrenden – sonst bliebe er unbemerkt. + const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null; const activity = asArray(snapshot?.commitActivity); const [favorite, setFavorite] = useState(project.favorite); @@ -125,10 +133,20 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
- {project.connection ? ( + {leading?.connection ? ( + ) : null} + + {weitere > 0 ? ( + repoTitle(repo)).join(", ")} + > + +{weitere} Repo + {weitere === 1 ? "" : "s"} ) : null} @@ -173,11 +191,8 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; ) : null} - {project.syncError ? ( - + {syncError ? ( + Sync-Fehler diff --git a/src/components/ProjectDialog.tsx b/src/components/ProjectDialog.tsx index 472f3ec..c963ffa 100644 --- a/src/components/ProjectDialog.tsx +++ b/src/components/ProjectDialog.tsx @@ -87,11 +87,14 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi accent: project.accent, tags: project.tags.join(", "), favorite: project.favorite, - connectionId: project.connectionId ?? "", - repoUrl: project.repoUrl ?? "", - repoOwner: project.repoOwner ?? "", - repoName: project.repoName ?? "", - autoSync: project.autoSync, + // Der Dialog bearbeitet das fuehrende Repository. Weitere + // verwaltet das Git-Panel – hier wuerde eine Liste den Dialog + // sprengen, in dem es eigentlich um das Projekt geht. + connectionId: project.repositories[0]?.connectionId ?? "", + repoUrl: project.repositories[0]?.repoUrl ?? "", + repoOwner: project.repositories[0]?.repoOwner ?? "", + repoName: project.repositories[0]?.repoName ?? "", + autoSync: project.repositories[0]?.autoSync ?? true, progressFromTasks: project.progressFromTasks, } : EMPTY, diff --git a/src/components/ProjectHeader.tsx b/src/components/ProjectHeader.tsx index f8ac77c..439e948 100644 --- a/src/components/ProjectHeader.tsx +++ b/src/components/ProjectHeader.tsx @@ -19,6 +19,7 @@ import { import { ProjectDialog } from "@/components/ProjectDialog"; import { StatusSelect } from "@/components/StatusSelect"; import { Popover } from "@/components/ui/Popover"; +import { repoTitle } from "@/lib/git/meta"; import { PRIORITY_META, accentOf } from "@/lib/status"; import { cn, formatDate } from "@/lib/utils"; import type { ConnectionSafe, ProjectDetail } from "@/types"; @@ -132,24 +133,22 @@ export function ProjectHeader({ angelegt {formatDate(project.createdAt)} Prioritaet: {priority.label} - {project.connection ? ( - - {project.connection.name} - · - {project.connection.provider} - - ) : null} - {project.repoUrl ? ( - - Repository - - - ) : null} + {/* Alle Repositories verlinken – bei einem einzigen sieht das aus + wie vorher, bei mehreren steht jedes fuer sich. */} + {project.repositories + .filter((repository) => repository.repoUrl) + .map((repository) => ( + + {project.repositories.length === 1 ? "Repository" : repoTitle(repository)} + + + ))}
{project.tags.length > 0 ? ( diff --git a/src/components/RepositoryDialog.tsx b/src/components/RepositoryDialog.tsx new file mode 100644 index 0000000..aa5a59b --- /dev/null +++ b/src/components/RepositoryDialog.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, RefreshCw, Search } from "lucide-react"; +import { Modal } from "@/components/ui/Modal"; +import type { ConnectionSafe, RepoListEntry, RepositoryWithSnapshot } from "@/types"; + +/** + * Ein weiteres Repository mit einem Projekt verknuepfen – oder ein + * vorhandenes umbenennen und umhaengen. + * + * Beim Anlegen des Projekts genuegt der Projektdialog: dort setzen die + * Git-Felder das erste Repository. Alles darueber hinaus laeuft hier. + */ + +interface FormState { + label: string; + connectionId: string; + repoUrl: string; + repoOwner: string; + repoName: string; + autoSync: boolean; +} + +const EMPTY: FormState = { + label: "", + connectionId: "", + repoUrl: "", + repoOwner: "", + repoName: "", + autoSync: true, +}; + +export function RepositoryDialog({ + open, + onClose, + projectId, + connections, + repository, +}: { + open: boolean; + onClose: () => void; + projectId: string; + connections: ConnectionSafe[]; + /** Vorhandenes = Bearbeiten, sonst Verknuepfen. */ + repository?: RepositoryWithSnapshot | null; +}) { + const router = useRouter(); + const [form, setForm] = useState(EMPTY); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const [repos, setRepos] = useState([]); + const [pickerOpen, setPickerOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(""); + + const isEdit = Boolean(repository); + + useEffect(() => { + if (!open) return; + setError(null); + setPickerOpen(false); + setRepos([]); + setFilter(""); + setForm( + repository + ? { + label: repository.label ?? "", + connectionId: repository.connectionId ?? "", + repoUrl: repository.repoUrl ?? "", + repoOwner: repository.repoOwner ?? "", + repoName: repository.repoName ?? "", + autoSync: repository.autoSync, + } + : EMPTY, + ); + }, [open, repository]); + + const gefiltert = useMemo(() => { + const needle = filter.toLowerCase().trim(); + if (!needle) return repos.slice(0, 60); + return repos.filter((repo) => repo.fullPath.toLowerCase().includes(needle)).slice(0, 60); + }, [repos, filter]); + + function update(key: K, value: FormState[K]) { + setForm((previous) => ({ ...previous, [key]: value })); + } + + async function loadRepos() { + if (!form.connectionId) { + setError("Bitte zuerst eine Git-Verbindung auswaehlen."); + return; + } + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/connections/${form.connectionId}/repos`); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error ?? "Repos konnten nicht geladen werden."); + setRepos(payload.repos ?? []); + setPickerOpen(true); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setLoading(false); + } + } + + function pick(repo: RepoListEntry) { + setForm((previous) => ({ + ...previous, + repoUrl: repo.htmlUrl, + repoOwner: repo.owner, + repoName: repo.name, + })); + setPickerOpen(false); + } + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setSaving(true); + setError(null); + try { + const response = await fetch( + isEdit ? `/api/repositories/${repository?.id}` : `/api/projects/${projectId}/repositories`, + { + method: isEdit ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + label: form.label, + connectionId: form.connectionId || null, + repoUrl: form.repoUrl, + repoOwner: form.repoOwner, + repoName: form.repoName, + autoSync: form.autoSync, + }), + }, + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen."); + onClose(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setSaving(false); + } + } + + return ( + +
+
+
+ + update("label", event.target.value)} + placeholder="Frontend" + maxLength={80} + /> +

+ Ohne Angabe steht schlicht owner/name da. +

+
+ +
+ + +
+ +
+ +
+ +
+ + update("repoUrl", event.target.value)} + placeholder="https://github.com/user/repo" + /> +
+ +
+ + update("repoOwner", event.target.value)} + placeholder="wird aus der URL abgeleitet" + /> +
+ +
+ + update("repoName", event.target.value)} + placeholder="wird aus der URL abgeleitet" + /> +
+
+ + + + {pickerOpen ? ( +
+
+ + setFilter(event.target.value)} + /> +
+
    + {gefiltert.map((repo) => ( +
  • + +
  • + ))} + {gefiltert.length === 0 ? ( +
  • Keine Treffer.
  • + ) : null} +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +}