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 { 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" }),
|
||||
},
|
||||
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user