import { useEffect, useRef, useState } from "react"; import { Plus, Pencil, X, HelpCircle, ChevronDown, ChevronRight, ArrowUp, ArrowDown } from "lucide-react"; import { api } from "../api"; import { useApp } from "../app-state"; import type { CommitInfo } from "../types"; import { Icon } from "./ui/Icon"; import { Button } from "./ui/Button"; const statusIcon: Record = { A: , M: , D: , "?": , }; export function GitPanel() { const { state, dispatch } = useApp(); const [commitMsg, setCommitMsg] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(null); const [pushBusy, setPushBusy] = useState(false); const [pullBusy, setPullBusy] = useState(false); const [remoteUrl, setRemoteUrl] = useState(""); const [showRemoteInput, setShowRemoteInput] = useState(false); const [log, setLog] = useState([]); const [logOpen, setLogOpen] = useState(false); useEffect(() => { if (state.commitFocus > 0) { inputRef.current?.focus(); } }, [state.commitFocus]); useEffect(() => { if (!state.currentProject) { setLog([]); return; } let cancelled = false; const project = state.currentProject; setLog([]); api .gitLog(project, 10) .then((r) => { if (!cancelled) setLog(r.commits); }) .catch(() => {}); return () => { cancelled = true; }; }, [state.currentProject, state.gitVersion]); const s = state.gitStatus; if (!state.currentProject) return null; const hasChanges = s && !s.clean; const canCommit = commitMsg.trim() && hasChanges && !busy; async function handleCommit() { if (!canCommit || !state.currentProject) return; setBusy(true); setError(null); try { let identity: { name?: string; email?: string } | undefined; try { const raw = localStorage.getItem("typst-leaf.git-identity"); const parsed = raw ? JSON.parse(raw) : undefined; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { identity = { name: typeof parsed.name === "string" ? parsed.name : undefined, email: typeof parsed.email === "string" ? parsed.email : undefined, }; } } catch { identity = undefined; } const res = await api.gitCommit(state.currentProject, commitMsg.trim(), identity); if (!res.ok) { setError(res.error ?? "commit failed"); } else { setCommitMsg(""); dispatch({ type: "bumpGitVersion" }); dispatch({ type: "pushNotice", kind: "success", message: "Committed" }); } } catch (e) { setError(String(e)); } finally { setBusy(false); } } async function handlePush() { if (!state.currentProject || pushBusy) return; setPushBusy(true); setError(null); try { if (!s?.hasRemote) { setShowRemoteInput(true); setPushBusy(false); return; } const res = await api.gitPush(state.currentProject); if (!res.ok) setError(res.error ?? "push failed"); else { dispatch({ type: "bumpGitVersion" }); dispatch({ type: "pushNotice", kind: "success", message: "Pushed" }); } } catch (e) { setError(String(e)); } finally { setPushBusy(false); } } async function handlePull() { if (!state.currentProject || pullBusy) return; setPullBusy(true); setError(null); try { if (!s?.hasRemote) { setShowRemoteInput(true); setPullBusy(false); return; } const res = await api.gitPull(state.currentProject); if (!res.ok) setError(res.error ?? "pull failed"); else { dispatch({ type: "bumpGitVersion" }); dispatch({ type: "pushNotice", kind: "success", message: "Pulled latest" }); } } catch (e) { setError(String(e)); } finally { setPullBusy(false); } } async function handleSetRemote() { if (!remoteUrl.trim() || !state.currentProject) return; try { await api.setRemote(state.currentProject, remoteUrl.trim()); setShowRemoteInput(false); setRemoteUrl(""); dispatch({ type: "bumpGitVersion" }); } catch (e) { setError(String(e)); } } function renderFileRow( f: { path: string; status: string }, group: "staged" | "unstaged" | "untracked", ) { const groupClasses: Record = { staged: "bg-teal-50 hover:bg-teal-100", unstaged: "hover:bg-surface-hover", untracked: "hover:bg-surface-hover", }; return (
dispatch({ type: "openFile", path: f.path })} > {statusIcon[f.status] ?? } {f.path}
); } return (
Git {s && ( {s.branch} {s.ahead > 0 && ( {s.ahead} )} {s.behind > 0 && ( {s.behind} )} )}
{s && !s.clean && ( {s.unstaged.length + s.staged.length} uncommitted )} {s && s.clean && clean}
{/* Changed files — dedupe by path; unstaged wins */} {s && !s.clean && (() => { const byPath = new Map(); for (const f of s.unstaged) byPath.set(f.path, { ...f, group: "unstaged" }); for (const f of s.staged) if (!byPath.has(f.path)) byPath.set(f.path, { ...f, group: "staged" }); const rows = Array.from(byPath.values()); return (
Changed files
{rows.map((f) => renderFileRow(f, f.group))}
); })()} {/* Commit compose */}
setCommitMsg(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void handleCommit(); }} placeholder={hasChanges ? "Commit message" : "No changes to commit"} disabled={!hasChanges} className="flex-1 min-w-0 px-2 py-1 text-sm bg-surface text-ink border border-line-strong rounded-md placeholder:text-ink-subtle outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-100 transition-colors duration-150 disabled:opacity-40 disabled:cursor-not-allowed" />
{showRemoteInput && (
setRemoteUrl(e.target.value)} placeholder="git remote URL" className="flex-1 min-w-0 px-2 py-1 text-sm bg-surface text-ink border border-line-strong rounded-md placeholder:text-ink-subtle outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-100" />
)} {error && (
{error}
)}
{/* Commit log */} {log.length > 0 && (
{logOpen && (
{log.map((c) => (
{c.sha.slice(0, 7)} {" "} {c.message}
))}
)}
)}
); }