feat: add Repos page with commit dialog, PR creation, and upstream badges

This commit is contained in:
plenarius
2026-04-20 21:57:52 -04:00
parent b224ebc927
commit 8849c25dff
5 changed files with 602 additions and 3 deletions
+2
View File
@@ -5,6 +5,7 @@ import { Editor } from "./pages/Editor";
import { Build } from "./pages/Build";
import { Server } from "./pages/Server";
import { Toolset } from "./pages/Toolset";
import { Repos } from "./pages/Repos";
import { IDELayout } from "./layouts/IDELayout";
import { FileExplorer } from "./components/editor/FileExplorer";
import { api } from "./services/api";
@@ -54,6 +55,7 @@ export function App() {
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} />
<Route path="repos" element={<Repos />} />
</Route>
</Routes>
</BrowserRouter>
@@ -0,0 +1,144 @@
import { useState, useMemo } from "react";
import { api } from "../services/api";
const COMMIT_TYPES = [
"feat", "fix", "refactor", "docs", "chore", "test", "style", "perf", "ci", "build", "revert",
] as const;
interface CommitDialogProps {
repo: string;
onClose: () => void;
onCommitted: () => void;
}
export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps) {
const [type, setType] = useState<string>("feat");
const [scope, setScope] = useState("");
const [description, setDescription] = useState("");
const [body, setBody] = useState("");
const [issueRef, setIssueRef] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const preview = useMemo(() => {
let msg = `${type}${scope ? `(${scope})` : ""}: ${description}`;
if (body) msg += `\n\n${body}`;
if (issueRef) msg += `\n\nFixes #${issueRef}`;
return msg;
}, [type, scope, description, body, issueRef]);
const isValid = description.trim().length > 0 && description.length <= 100;
async function handleSubmit(andPush: boolean) {
setError("");
setLoading(true);
try {
await api.repos.commit(repo, { type, scope: scope || undefined, message: description, body: body || undefined, issueRef: issueRef || undefined });
if (andPush) {
await api.repos.push(repo);
}
onCommitted();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Commit failed");
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="w-full max-w-lg rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Commit Changes
</h3>
<div className="mb-3 flex gap-2">
<select
value={type}
onChange={(e) => setType(e.target.value)}
className="rounded border px-2 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
{COMMIT_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<input
type="text"
value={scope}
onChange={(e) => setScope(e.target.value)}
placeholder="scope (optional)"
className="w-28 rounded border px-2 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
/>
</div>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 100))}
placeholder="Description (required, max 100 chars)"
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Body (optional)"
rows={3}
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
/>
<input
type="text"
value={issueRef}
onChange={(e) => setIssueRef(e.target.value.replace(/\D/g, ""))}
placeholder="Issue # (auto-formats as Fixes #NNN)"
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
/>
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "'JetBrains Mono', monospace" }}>
<div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
<pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre>
</div>
{error && (
<div className="mb-3 rounded bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border px-3 py-1.5 text-sm"
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Cancel
</button>
<button
onClick={() => handleSubmit(false)}
disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
>
Commit
</button>
<button
onClick={() => handleSubmit(true)}
disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
>
Commit & Push
</button>
</div>
</div>
</div>
);
}
+55 -3
View File
@@ -1,14 +1,42 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { useState, useEffect } from "react";
import { Outlet, Link, useLocation } from "react-router-dom";
import { Terminal } from "../components/terminal/Terminal";
import { useWebSocket } from "../hooks/useWebSocket";
const NAV_ITEMS = [
{ path: "/", label: "Home" },
{ path: "/editor", label: "Editor" },
{ path: "/build", label: "Build" },
{ path: "/server", label: "Server" },
{ path: "/toolset", label: "Toolset" },
{ path: "/repos", label: "Repos" },
];
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
const [terminalOpen, setTerminalOpen] = useState(false);
const [upstreamBehind, setUpstreamBehind] = useState(0);
const location = useLocation();
const { subscribe } = useWebSocket();
useEffect(() => {
return subscribe("git:upstream-update", (event) => {
const data = event.data as { behind: number };
if (data.behind > 0) {
setUpstreamBehind((prev) => prev + data.behind);
}
});
}, [subscribe]);
useEffect(() => {
if (location.pathname === "/repos") {
setUpstreamBehind(0);
}
}, [location.pathname]);
return (
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
<header
className="flex shrink-0 items-center px-4 py-2"
className="flex shrink-0 items-center gap-6 px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<h1
@@ -20,6 +48,30 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
>
Layonara Forge
</h1>
<nav className="flex items-center gap-1">
{NAV_ITEMS.map((item) => {
const isActive =
item.path === "/" ? location.pathname === "/" : location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
className="relative rounded px-2.5 py-1 text-sm transition-colors hover:bg-white/5"
style={{
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
fontWeight: isActive ? 600 : 400,
}}
>
{item.label}
{item.path === "/repos" && upstreamBehind > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-black">
{upstreamBehind}
</span>
)}
</Link>
);
})}
</nav>
</header>
<div className="flex flex-1 flex-col overflow-hidden">
+371
View File
@@ -0,0 +1,371 @@
import { useState, useEffect, useCallback } from "react";
import { api } from "../services/api";
import { CommitDialog } from "../components/CommitDialog";
import { useWebSocket } from "../hooks/useWebSocket";
interface RepoStatus {
modified: string[];
staged: string[];
untracked: string[];
ahead: number;
behind: number;
branch: string;
}
interface RepoInfo {
name: string;
branch: string;
upstream: string;
cloned: boolean;
status?: RepoStatus;
}
interface PrForm {
repo: string;
title: string;
body: string;
}
export function Repos() {
const [repos, setRepos] = useState<RepoInfo[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [commitRepo, setCommitRepo] = useState<string | null>(null);
const [prForm, setPrForm] = useState<PrForm | null>(null);
const [prResult, setPrResult] = useState<{ url: string } | null>(null);
const [diffView, setDiffView] = useState<{ repo: string; file?: string; diff: string } | null>(null);
const [error, setError] = useState("");
const { subscribe } = useWebSocket();
const fetchRepos = useCallback(async () => {
try {
const data = (await api.repos.list()) as RepoInfo[];
setRepos(data);
} catch {
setError("Failed to load repos");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRepos();
}, [fetchRepos]);
useEffect(() => {
return subscribe("git:upstream-update", (event) => {
const data = event.data as { repo: string; behind: number };
setRepos((prev) =>
prev.map((r) =>
r.name === data.repo && r.status
? { ...r, status: { ...r.status, behind: data.behind } }
: r,
),
);
});
}, [subscribe]);
async function handleClone(repoName: string) {
setActionLoading((p) => ({ ...p, [repoName]: true }));
try {
await api.repos.clone(repoName);
await fetchRepos();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Clone failed");
} finally {
setActionLoading((p) => ({ ...p, [repoName]: false }));
}
}
async function handlePull(repoName: string) {
setActionLoading((p) => ({ ...p, [repoName]: true }));
try {
await api.repos.pull(repoName);
await fetchRepos();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Pull failed");
} finally {
setActionLoading((p) => ({ ...p, [repoName]: false }));
}
}
async function handlePush(repoName: string) {
setActionLoading((p) => ({ ...p, [repoName]: true }));
try {
await api.repos.push(repoName);
await fetchRepos();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Push failed");
} finally {
setActionLoading((p) => ({ ...p, [repoName]: false }));
}
}
async function handleShowDiff(repoName: string, file?: string) {
try {
const { diff } = await api.repos.diff(repoName);
setDiffView({ repo: repoName, file, diff });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to get diff");
}
}
async function handleCreatePr() {
if (!prForm) return;
setActionLoading((p) => ({ ...p, pr: true }));
try {
const repo = repos.find((r) => r.name === prForm.repo);
const result = await api.github.createPr(
prForm.repo,
prForm.title,
prForm.body,
repo?.status?.branch || repo?.branch || "main",
);
setPrResult({ url: result.url });
setPrForm(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "PR creation failed");
} finally {
setActionLoading((p) => ({ ...p, pr: false }));
}
}
const isDirty = (status?: RepoStatus) =>
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0);
if (loading) {
return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
Loading repositories...
</div>
);
}
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Repositories
</h2>
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button>
</div>
)}
{prResult && (
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400">
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a>
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button>
</div>
)}
<div className="space-y-4">
{repos.map((repo) => (
<section
key={repo.name}
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold">{repo.name}</h3>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{repo.branch}
</span>
{repo.cloned && repo.status && (
<>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${isDirty(repo.status) ? "bg-yellow-500/20 text-yellow-400" : "bg-green-500/20 text-green-400"}`}>
{isDirty(repo.status) ? "dirty" : "clean"}
</span>
{repo.status.behind > 0 && (
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-400">
{repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream
</span>
)}
{repo.status.ahead > 0 && (
<span className="rounded-full bg-blue-500/20 px-2 py-0.5 text-xs font-medium text-blue-400">
{repo.status.ahead} ahead
</span>
)}
</>
)}
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{repo.upstream}
</span>
</div>
{!repo.cloned ? (
<button
onClick={() => handleClone(repo.name)}
disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
>
{actionLoading[repo.name] ? "Cloning..." : "Clone"}
</button>
) : (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button
onClick={() => handlePull(repo.name)}
disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Pull
</button>
<button
onClick={() => handlePush(repo.name)}
disabled={actionLoading[repo.name] || !repo.status?.ahead}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Push
</button>
<button
onClick={() => setCommitRepo(repo.name)}
disabled={!isDirty(repo.status)}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
>
Commit
</button>
<button
onClick={() =>
setPrForm({
repo: repo.name,
title: "",
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`,
})
}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity"
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
>
Create PR
</button>
</div>
{repo.status && isDirty(repo.status) && (
<div className="mt-2">
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
Changes
</div>
<div className="space-y-0.5">
{repo.status.modified.map((f) => (
<div
key={f}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 text-xs transition-colors hover:bg-white/5"
onClick={() => handleShowDiff(repo.name, f)}
>
<span className="font-medium text-yellow-400">M</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
</div>
))}
{repo.status.staged.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-green-400">S</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
</div>
))}
{repo.status.untracked.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-gray-400">?</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
</div>
))}
</div>
</div>
)}
</>
)}
</section>
))}
</div>
{commitRepo && (
<CommitDialog
repo={commitRepo}
onClose={() => setCommitRepo(null)}
onCommitted={() => {
setCommitRepo(null);
fetchRepos();
}}
/>
)}
{prForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setPrForm(null)}>
<div
className="w-full max-w-lg rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Create Pull Request {prForm.repo}
</h3>
<input
type="text"
value={prForm.title}
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
placeholder="PR Title"
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
/>
<textarea
value={prForm.body}
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
rows={8}
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)", fontFamily: "'JetBrains Mono', monospace" }}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setPrForm(null)}
className="rounded border px-3 py-1.5 text-sm"
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Cancel
</button>
<button
onClick={handleCreatePr}
disabled={!prForm.title.trim() || actionLoading.pr}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
>
{actionLoading.pr ? "Creating..." : "Submit PR"}
</button>
</div>
</div>
</div>
)}
{diffView && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDiffView(null)}>
<div
className="h-3/4 w-3/4 overflow-auto rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Diff {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
</h3>
<button onClick={() => setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}>
Close
</button>
</div>
<pre
className="whitespace-pre-wrap text-xs"
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }}
>
{diffView.diff || "No changes"}
</pre>
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -117,6 +117,36 @@ export const api = {
discardAll: () => request("/toolset/discard-all", { method: "POST" }),
},
github: {
validatePat: (pat: string) =>
request<{ login: string }>("/github/validate-pat", {
method: "POST",
body: JSON.stringify({ pat }),
}),
fork: (repo: string) =>
request("/github/fork", { method: "POST", body: JSON.stringify({ repo }) }),
forks: () => request<Array<{ repo: string; forked: boolean }>>("/github/forks"),
createPr: (repo: string, title: string, body: string, headBranch: string) =>
request<{ number: number; url: string }>("/github/pr", {
method: "POST",
body: JSON.stringify({ repo, title, body, headBranch }),
}),
listPrs: (repo: string) =>
request<Array<Record<string, unknown>>>(`/github/prs/${repo}`),
},
repos: {
list: () => request<Array<Record<string, unknown>>>("/repos"),
status: (repo: string) => request<Record<string, unknown>>(`/repos/${repo}/status`),
clone: (repo: string) =>
request("/repos/clone", { method: "POST", body: JSON.stringify({ repo }) }),
pull: (repo: string) => request(`/repos/${repo}/pull`, { method: "POST" }),
commit: (repo: string, data: Record<string, unknown>) =>
request(`/repos/${repo}/commit`, { method: "POST", body: JSON.stringify(data) }),
push: (repo: string) => request(`/repos/${repo}/push`, { method: "POST" }),
diff: (repo: string) => request<{ diff: string }>(`/repos/${repo}/diff`),
},
docker: {
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
pull: (image: string) =>