ae0d888ddf
Palette overhaul: - Warm cream canvas (#faf8f4) + near-white surfaces (#fdfdfb) + deep teal accent (#0f766e) - Unified on stone-warm neutrals via @theme block in index.css Typography: - Inter Variable + JetBrains Mono Variable via fontsource (self-hosted) - Monaco editor set to JetBrains Mono for consistency Icons: - lucide-react replaces all 8 hand-rolled SVGs - Leaf brand mark beside wordmark Shared primitives (src/components/ui/): - Icon, Button (4 variants, 2 sizes), IconButton, TextInput, SectionHeader, StatusDot, Chip Custom Monaco theme (src/theme/monaco-theme.ts): - typst-leaf-light: warm near-white background, restrained rainbow-free syntax - Keywords teal, strings warm green, comments stone italic, most tokens ink Component reworks: - TopBar: Leaf icon, h-12, chip-based status, Button/IconButton primitives - Sidebar: w-72, icon+label tabs with teal bottom border - StatusBar: lucide GitBranch/ArrowUp/ArrowDown, chip-based status - FileTree: lucide Plus/FolderPlus/Pencil/Trash2, teal active state - GitPanel: lucide status icons, restored staged/unstaged bg tint - Preview: RotateCw reload icon, chip status - EmptyState: muted Leaf icon - ToastHost: StatusDot + lucide X Fixes: - GitPanel staged/unstaged/untracked rows now have distinct bg tints - Button defaults to type="button" to prevent accidental form submission - Editor error fallback from red-400 to rose-600 - DESIGN.md: removed emoji from layout skeleton, fixed Modals/Monaco typo Build: pnpm -r typecheck + pnpm build pass.
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
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<string, React.ReactNode> = {
|
|
A: <Icon icon={Plus} className="size-3 text-teal-600" />,
|
|
M: <Icon icon={Pencil} className="size-3 text-amber-600" />,
|
|
D: <Icon icon={X} className="size-3 text-rose-500" />,
|
|
"?": <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />,
|
|
};
|
|
|
|
export function GitPanel() {
|
|
const { state, dispatch } = useApp();
|
|
const [commitMsg, setCommitMsg] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const [pushBusy, setPushBusy] = useState(false);
|
|
const [pullBusy, setPullBusy] = useState(false);
|
|
const [remoteUrl, setRemoteUrl] = useState("");
|
|
const [showRemoteInput, setShowRemoteInput] = useState(false);
|
|
const [log, setLog] = useState<CommitInfo[]>([]);
|
|
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<string, string> = {
|
|
staged: "bg-teal-50 hover:bg-teal-100",
|
|
unstaged: "hover:bg-surface-hover",
|
|
untracked: "hover:bg-surface-hover",
|
|
};
|
|
return (
|
|
<div
|
|
key={f.path}
|
|
className={`flex items-center gap-1.5 text-xs font-mono cursor-pointer px-1 py-0.5 rounded transition-colors ${groupClasses[group]}`}
|
|
onClick={() => dispatch({ type: "openFile", path: f.path })}
|
|
>
|
|
<span className="w-3.5 flex items-center justify-center">
|
|
{statusIcon[f.status] ?? <Icon icon={HelpCircle} className="size-3 text-ink-subtle" />}
|
|
</span>
|
|
<span className="truncate text-ink-muted">{f.path}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="border-t border-line">
|
|
<div className="px-3 py-1.5 text-xs uppercase tracking-wide text-ink-subtle flex items-center gap-1">
|
|
<span>Git</span>
|
|
{s && (
|
|
<span className="ml-auto text-ink-muted font-mono normal-case flex items-center gap-1">
|
|
<span>{s.branch}</span>
|
|
{s.ahead > 0 && (
|
|
<span className="text-teal-600 flex items-center gap-0.5">
|
|
<Icon icon={ArrowUp} className="size-2.5" />{s.ahead}
|
|
</span>
|
|
)}
|
|
{s.behind > 0 && (
|
|
<span className="text-amber-600 flex items-center gap-0.5">
|
|
<Icon icon={ArrowDown} className="size-2.5" />{s.behind}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-3 pb-1.5 text-xs text-ink-muted">
|
|
{s && !s.clean && (
|
|
<span className="text-amber-600">
|
|
{s.unstaged.length + s.staged.length} uncommitted
|
|
</span>
|
|
)}
|
|
{s && s.clean && <span className="text-teal-600">clean</span>}
|
|
</div>
|
|
|
|
{/* Changed files — dedupe by path; unstaged wins */}
|
|
{s && !s.clean && (() => {
|
|
const byPath = new Map<string, { path: string; status: string; group: "staged" | "unstaged" }>();
|
|
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 (
|
|
<div className="px-3 pb-1">
|
|
<div className="text-[10px] uppercase tracking-wider text-ink-subtle mb-0.5">
|
|
Changed files
|
|
</div>
|
|
{rows.map((f) => renderFileRow(f, f.group))}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Commit compose */}
|
|
<div className="px-3 pb-1.5 flex flex-col gap-1.5">
|
|
<div className="flex gap-1">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={commitMsg}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<Button size="sm" onClick={() => void handleCommit()} disabled={!canCommit}>
|
|
{busy ? "..." : "Commit"}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="flex-1"
|
|
onClick={() => void handlePush()}
|
|
disabled={pushBusy}
|
|
>
|
|
<Icon icon={ArrowUp} className="size-3" />
|
|
{pushBusy ? "..." : "Push"}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
className="flex-1"
|
|
onClick={() => void handlePull()}
|
|
disabled={pullBusy}
|
|
>
|
|
<Icon icon={ArrowDown} className="size-3" />
|
|
{pullBusy ? "..." : "Pull"}
|
|
</Button>
|
|
</div>
|
|
|
|
{showRemoteInput && (
|
|
<div className="flex gap-1">
|
|
<input
|
|
type="text"
|
|
value={remoteUrl}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<Button size="sm" onClick={() => void handleSetRemote()} disabled={!remoteUrl.trim()}>
|
|
Set
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="text-xs text-rose-600 break-words">{error}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Commit log */}
|
|
{log.length > 0 && (
|
|
<div className="border-t border-line">
|
|
<button
|
|
type="button"
|
|
onClick={() => setLogOpen(!logOpen)}
|
|
className="w-full flex items-center gap-1 px-3 py-1 text-xs text-ink-subtle hover:text-ink uppercase tracking-wide transition-colors"
|
|
>
|
|
<span>Log</span>
|
|
<span className="ml-auto">
|
|
<Icon icon={logOpen ? ChevronDown : ChevronRight} className="size-3" />
|
|
</span>
|
|
</button>
|
|
{logOpen && (
|
|
<div className="max-h-32 overflow-y-auto px-3 pb-1.5 flex flex-col gap-1">
|
|
{log.map((c) => (
|
|
<div
|
|
key={c.sha}
|
|
className="text-xs text-ink-muted leading-tight"
|
|
title={`${c.sha} — ${c.author} on ${c.date}`}
|
|
>
|
|
<span className="font-mono text-ink-subtle">{c.sha.slice(0, 7)}</span>
|
|
{" "}
|
|
<span className="text-ink">{c.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|