feat: add Repos page with commit dialog, PR creation, and upstream badges
This commit is contained in:
@@ -5,6 +5,7 @@ import { Editor } from "./pages/Editor";
|
|||||||
import { Build } from "./pages/Build";
|
import { Build } from "./pages/Build";
|
||||||
import { Server } from "./pages/Server";
|
import { Server } from "./pages/Server";
|
||||||
import { Toolset } from "./pages/Toolset";
|
import { Toolset } from "./pages/Toolset";
|
||||||
|
import { Repos } from "./pages/Repos";
|
||||||
import { IDELayout } from "./layouts/IDELayout";
|
import { IDELayout } from "./layouts/IDELayout";
|
||||||
import { FileExplorer } from "./components/editor/FileExplorer";
|
import { FileExplorer } from "./components/editor/FileExplorer";
|
||||||
import { api } from "./services/api";
|
import { api } from "./services/api";
|
||||||
@@ -54,6 +55,7 @@ export function App() {
|
|||||||
<Route path="build" element={<Build />} />
|
<Route path="build" element={<Build />} />
|
||||||
<Route path="server" element={<Server />} />
|
<Route path="server" element={<Server />} />
|
||||||
<Route path="toolset" element={<Toolset />} />
|
<Route path="toolset" element={<Toolset />} />
|
||||||
|
<Route path="repos" element={<Repos />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,42 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet, Link, useLocation } from "react-router-dom";
|
||||||
import { Terminal } from "../components/terminal/Terminal";
|
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 }) {
|
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
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 (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||||
<header
|
<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)" }}
|
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||||
>
|
>
|
||||||
<h1
|
<h1
|
||||||
@@ -20,6 +48,30 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
Layonara Forge
|
Layonara Forge
|
||||||
</h1>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,6 +117,36 @@ export const api = {
|
|||||||
discardAll: () => request("/toolset/discard-all", { method: "POST" }),
|
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: {
|
docker: {
|
||||||
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
|
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
|
||||||
pull: (image: string) =>
|
pull: (image: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user