diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 3adcfa1..d5fe969 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/packages/frontend/src/components/CommitDialog.tsx b/packages/frontend/src/components/CommitDialog.tsx new file mode 100644 index 0000000..a8efd7a --- /dev/null +++ b/packages/frontend/src/components/CommitDialog.tsx @@ -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("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 ( + + e.stopPropagation()} + > + + Commit Changes + + + + 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) => ( + {t} + ))} + + 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)" }} + /> + + + 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)" }} + /> + + 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)" }} + /> + + 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)" }} + /> + + + Preview: + {preview} + + + {error && ( + {error} + )} + + + + Cancel + + 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 + + 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 + + + + + ); +} diff --git a/packages/frontend/src/layouts/IDELayout.tsx b/packages/frontend/src/layouts/IDELayout.tsx index 6daa6bd..040411f 100644 --- a/packages/frontend/src/layouts/IDELayout.tsx +++ b/packages/frontend/src/layouts/IDELayout.tsx @@ -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 ( Layonara Forge + + {NAV_ITEMS.map((item) => { + const isActive = + item.path === "/" ? location.pathname === "/" : location.pathname.startsWith(item.path); + return ( + + {item.label} + {item.path === "/repos" && upstreamBehind > 0 && ( + + {upstreamBehind} + + )} + + ); + })} + diff --git a/packages/frontend/src/pages/Repos.tsx b/packages/frontend/src/pages/Repos.tsx new file mode 100644 index 0000000..1384555 --- /dev/null +++ b/packages/frontend/src/pages/Repos.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState>({}); + const [commitRepo, setCommitRepo] = useState(null); + const [prForm, setPrForm] = useState(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 ( + + Loading repositories... + + ); + } + + return ( + + + Repositories + + + {error && ( + + {error} + setError("")} className="ml-2 underline">dismiss + + )} + + {prResult && ( + + PR created: {prResult.url} + setPrResult(null)} className="ml-2 underline">dismiss + + )} + + + {repos.map((repo) => ( + + + + {repo.name} + + {repo.branch} + + {repo.cloned && repo.status && ( + <> + + {isDirty(repo.status) ? "dirty" : "clean"} + + {repo.status.behind > 0 && ( + + {repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream + + )} + {repo.status.ahead > 0 && ( + + {repo.status.ahead} ahead + + )} + > + )} + + + {repo.upstream} + + + + {!repo.cloned ? ( + 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"} + + ) : ( + <> + + 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 + + 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 + + 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 + + + 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 + + + + {repo.status && isDirty(repo.status) && ( + + + Changes + + + {repo.status.modified.map((f) => ( + handleShowDiff(repo.name, f)} + > + M + {f} + + ))} + {repo.status.staged.map((f) => ( + + S + {f} + + ))} + {repo.status.untracked.map((f) => ( + + ? + {f} + + ))} + + + )} + > + )} + + ))} + + + {commitRepo && ( + setCommitRepo(null)} + onCommitted={() => { + setCommitRepo(null); + fetchRepos(); + }} + /> + )} + + {prForm && ( + setPrForm(null)}> + e.stopPropagation()} + > + + Create Pull Request — {prForm.repo} + + 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)" }} + /> + 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" }} + /> + + setPrForm(null)} + className="rounded border px-3 py-1.5 text-sm" + style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }} + > + Cancel + + + {actionLoading.pr ? "Creating..." : "Submit PR"} + + + + + )} + + {diffView && ( + setDiffView(null)}> + e.stopPropagation()} + > + + + Diff — {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""} + + setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}> + Close + + + + {diffView.diff || "No changes"} + + + + )} + + ); +} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index c01bdbd..4b9e6c3 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -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>("/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>>(`/github/prs/${repo}`), + }, + + repos: { + list: () => request>>("/repos"), + status: (repo: string) => request>(`/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) => + 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>>("/docker/containers"), pull: (image: string) =>
{preview}
+ {diffView.diff || "No changes"} +