/** * @file The Changes panel: the lane's code beside its console, in the five * shapes a review actually needs — * * overview — every changed file with its +/- counts and its stage/discard * actions; click one to open it * review — every file's full diff, stacked, for reading a whole change-set * file — one file, as a diff or as its whole content (issue #35); a * whole markdown file can also be read rendered (Preview) * history — recent commits; opening one shows that commit's diff * browse — quick-open by path, or the repo's tracked file tree * * and three ranges: `work` (uncommitted, what the agent has touched right * now), `branch` (`base...HEAD`, what this lane adds on top of its base) and * `commit` (one commit, reached from history). * * Only the overview polls. Review, file, history and browse are a deliberate * read of a frozen moment — re-fetching under the reader's scroll position is * worse than a slightly stale view, and those are the expensive shapes. * * Rendering lives in LaneDiffView.tsx. Every write goes through * `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here * before the server is told `confirm: true` — it is the one action in this * panel git cannot undo. * * During a merge (issue #45) a banner offers **Complete merge** (only once no * file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and * conflicted files get their own group with Keep mine / Take theirs / Mark * resolved instead of stage — staging one blind would mark it resolved. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsDownUp, ChevronsUpDown, Check, Code2, Columns2, Eye, FileText, FoldVertical, Folder, Minus, Pencil, Plus, RefreshCw, Rows3, Trash2, UnfoldVertical, } from "lucide-react"; import { api } from "../../lib/api"; import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView"; import { LaneConflictView } from "./LaneConflictView"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import type { LaneChangeFile, LaneChangeRange, LaneChanges, LaneCommit, LaneTreeEntry, } from "../../lib/types"; /** How often the overview re-reads. Summary only — no patch text. */ const OVERVIEW_REFRESH_MS = 15_000; /** Context steps the expand button walks through, then the whole file. */ const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000]; const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!; type Mode = "overview" | "review" | "file" | "history" | "browse"; /** Colour per porcelain status letter, matching LaneGitHeader's file panel. */ function statusClass(status: string, untracked: boolean) { if (untracked) return "text-status-success"; const code = status.trim()[0]; if (code === "A") return "text-status-success"; if (code === "D") return "text-status-danger"; if (code === "R") return "text-accent"; return "text-status-warning"; } /** `+12 −3`, with the halves coloured and a zero side dropped. A binary file * has no line delta at all, so it says `bin` rather than leaving the row's * right edge blank, which reads as "changed by nothing". */ function Delta({ added, deleted, binary, }: { added: number | null; deleted: number | null; binary?: boolean; }) { const { t } = useTranslation(["lanes"]); if (binary) return {t("changes.bin")}; return ( {added ? +{added} : null} {added && deleted ? " " : null} {deleted ? −{deleted} : null} ); } /** Why a whole-file read came back without an edit stamp (`sha`): the i18n * key naming it, so the reader is never left with a button that does nothing. */ function noEditReason(text: { binary?: boolean; truncated: boolean }) { if (text.binary) return "changes.noEditBinary"; if (text.truncated) return "changes.noEditTooBig"; return "changes.noEdit"; } /** The explorer's decoration for a porcelain code: one letter + its colour, * VS Code's convention (U untracked, A added, D deleted, R renamed, M else). */ function treeMark(code: string): { letter: string; tone: string } { // The tint fills the whole row, not just the letter, so a changed file // stands out of a long tree at a glance. if (code === "??") return { letter: "U", tone: "text-status-success bg-status-success/10" }; // A merge conflict (git's unmerged codes) before A/D: `AA`/`DU` are not adds or deletes. if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code)) return { letter: "!", tone: "text-status-danger bg-status-danger/10" }; if (code.includes("A")) return { letter: "A", tone: "text-status-success bg-status-success/10" }; if (code.includes("D")) return { letter: "D", tone: "text-status-danger bg-status-danger/10" }; if (code.includes("R")) return { letter: "R", tone: "text-status-warning bg-status-warning/10" }; return { letter: "M", tone: "text-status-warning bg-status-warning/10" }; } /** * One folder level of the browse tree, rendered in place and recursing into * every expanded child — so opening a nested file no longer means navigating * away from its siblings. */ function TreeNodes({ path, depth, children, expanded, onToggleDir, onOpenFile, active, }: { path: string; depth: number; children: Record; expanded: Set; onToggleDir: (path: string) => void; onOpenFile: (path: string) => void; /** The file open beside the tree, drawn selected. */ active?: string | null; }) { const entries = children[path]; if (!entries) return null; return ( <> {entries.map((e) => { const mark = e.status ? treeMark(e.status) : null; return (
  • {e.dir && expanded.has(e.path) && (
    )}
  • ); })} ); } function IconButton({ label, onClick, disabled, danger, children, }: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean; children: React.ReactNode; }) { return ( ); } /** A hunk's Undo/Keep chip in its review bar: labelled, so the two read apart * at a glance instead of as two grey glyphs. */ const HUNK_CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40"; type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void; /** A work-range file's stage / unstage / discard buttons — the overview row's * and the review header's. */ function FileActions({ f, busy, armed, onAct, onArm, }: { f: LaneChangeFile; busy: boolean; armed: boolean; onAct: FileAct; onArm: (path: string) => void; }) { const { t } = useTranslation(["lanes"]); return ( {f.staged ? ( onAct("unstage", f.path)} > ) : null} {f.unstaged || f.untracked ? ( onAct("stage", f.path)} > ) : null} { // Two clicks, always: the first arms the row, the second is the // only one that reaches the server (which itself refuses the // call without `confirm: true`). if (armed) onAct("discard", f.path, true); else onArm(f.path); }} > ); } type Resolve = (path: string, side: "ours" | "theirs" | "done") => void; /** A conflicted file's three ways out of the conflict. */ function ConflictActions({ f, busy, onResolve, }: { f: LaneChangeFile; busy: boolean; onResolve: Resolve; }) { const { t } = useTranslation(["lanes"]); return ( {(["ours", "theirs"] as const).map((side) => ( ))} onResolve(f.path, "done")} > ); } /** One overview row. At module scope on purpose: declared inside the panel it * was a new component type every render, so each poll and each keystroke in * the commit box remounted every row (focus dropped to ). */ function ChangeRow({ f, range, busy, armed, onOpen, onAct, onArm, onResolve, }: { f: LaneChangeFile; range: LaneChangeRange; onResolve: Resolve; busy: boolean; /** This row's discard has been clicked once and awaits the second click. */ armed: boolean; onOpen: (path: string) => void; onAct: FileAct; onArm: (path: string) => void; }) { const { t } = useTranslation(["lanes"]); return (
  • {range === "work" && (f.conflicted ? ( ) : ( ))}
  • ); } export function LaneChangesPanel({ laneId, onNewLane, full = false, }: { laneId: number; /** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */ onNewLane?: (branch?: string) => void; /** The panel has the whole width: the file tree becomes a sidebar and a * whole file opens straight into the editor. */ full?: boolean; }) { const { t } = useTranslation(["lanes"]); // One read of the working copy, shared by the header and every write below: // a stage, a discard or a commit changes the counts the header shows. const [git, refreshGit] = useLaneGitFacts(laneId); // Bumped by Refresh and by a git bar action: a pull or a switch moves // history and the tree too, not just the diff `load` re-reads. const [reread, setReread] = useState(0); const [range, setRange] = useState("work"); const [mode, setMode] = useState("overview"); const [openFile, setOpenFile] = useState(null); const [sha, setSha] = useState(null); const [context, setContext] = useState(CONTEXT_STEPS[0]); const [layout, setLayout] = useState("unified"); const [whole, setWhole] = useState(false); // A whole markdown file shown rendered instead of as source; per file. const [mdPreview, setMdPreview] = useState(false); useEffect(() => setMdPreview(false), [openFile]); const [data, setData] = useState(null); const [commits, setCommits] = useState(null); // A folder's children, keyed by its path ("" is the root) — fetched once, // on first expand, and kept so re-collapsing doesn't re-fetch. const [treeChildren, setTreeChildren] = useState>({}); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [query, setQuery] = useState(""); const [hits, setHits] = useState(null); const [text, setText] = useState<{ path: string; lines: string[]; truncated: boolean; binary?: boolean; sha?: string; } | null>(null); // The whole-file editor's text while it is open; null = viewing. const [draft, setDraft] = useState(null); const editWanted = useRef(false); // Where the editor opens: the line clicked in the read view, and that view's // scroll offset, so the text does not jump when the textarea replaces it. const editAt = useRef<{ line: number; scrollTop: number } | null>(null); const editorRef = useRef(null); const editing = draft !== null; useEffect(() => { const at = editAt.current; const el = editorRef.current; if (!editing || !at || !el) return; editAt.current = null; const offset = el.value .split("\n") .slice(0, at.line) .reduce((n, l) => n + l.length + 1, 0); el.setSelectionRange(offset, offset); el.focus({ preventScroll: true }); el.scrollTop = at.scrollTop; }, [editing]); const startEdit = (line: number, scrollTop: number) => { if (!text?.sha) return; editAt.current = { line, scrollTop }; setDraft(text.lines.join("\n")); }; useEffect(() => { if (!editWanted.current || !text) return; editWanted.current = false; if (text.sha) startEdit(0, 0); // Asked for from the diff, refused once the file arrived: say why here, // not with an Edit button that silently does nothing. else setError(t(noEditReason(text))); // eslint-disable-next-line react-hooks/exhaustive-deps }, [text]); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [confirmDiscard, setConfirmDiscard] = useState(null); const [armedAbort, setArmedAbort] = useState(false); // A conflicted file opens in the conflict view; true = the plain editor. const [rawEdit, setRawEdit] = useState(false); // The hunk whose Undo was clicked once: `${path}\n${header}`. const [armedHunk, setArmedHunk] = useState(null); // Files folded shut in review mode. A change-set is read file by file, and // a file already read is in the way of the next one — so the set is what is // COLLAPSED, and a file arriving later is open by default. const [collapsed, setCollapsed] = useState>(new Set()); // Where opening a file came FROM: its mode and how far the list was scrolled. // Reviewing a change-set is a walk down a long list, and landing back at the // top of it after every file is the whole cost of opening one. const scroller = useRef(null); const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null); const pendingScroll = useRef(null); const [message, setMessage] = useState(""); const [error, setError] = useState(null); // Every diff read gets a ticket; only the newest may land. A lane switch (or // a mode/range change) re-issues the read, and without this a slow reply for // the previous lane painted its files — and its discard buttons — over this one. const loadSeq = useRef(0); // The same guard for the side reads (history, tree, whole file), which only // race across a lane switch: a reply for a lane no longer shown is dropped. const laneNow = useRef(laneId); laneNow.current = laneId; const forLane = (id: number, fn: (v: T) => void) => (v: T) => { if (laneNow.current === id) fn(v); }; const load = useCallback(async () => { const seq = ++loadSeq.current; setLoading(true); try { const result = await api.lanes.changes(laneId, { range, ...(range === "commit" && sha ? { sha } : {}), ...(mode === "file" && openFile ? { path: openFile, context } : {}), ...(mode === "review" ? { full: true } : {}), }); if (seq !== loadSeq.current) return; setData(result); setError(null); } catch (err) { if (seq !== loadSeq.current) return; // A tracked file opened from Files that has nothing to diff: a normal // state, not a fault. The whole-file view is the answer; no banner. if (mode === "file" && (err as { code?: string })?.code === "ENOFILECHANGE") { setData({ available: true, range, base: null, baseRef: null, sha, context, files: [], truncated: false, }); setError(null); return; } setError(err instanceof Error ? err.message : String(err)); } finally { if (seq === loadSeq.current) setLoading(false); } }, [laneId, range, sha, mode, openFile, context]); useEffect(() => { if (mode === "history" || mode === "browse") return; void load(); // Only the cheap shape polls; see the file header. if (mode !== "overview") return; const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS); return () => window.clearInterval(timer); }, [load, mode]); // History and the file tree are their own reads, each fetched when its mode // is entered rather than alongside the diff nobody is looking at. useEffect(() => { if (mode !== "history") return; void api.lanes .gitLog(laneId, { limit: 50 }) .then(forLane(laneId, (r) => setCommits(r.commits))) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, [mode, laneId, reread]); useEffect(() => { const wanted = full || (mode === "browse" && !query.trim()); if (!wanted || "" in treeChildren) return; void api.lanes .gitTree(laneId, "") .then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))) .catch((err) => setError(err instanceof Error ? err.message : String(err))); // `treeChildren` is the cache this fills — reading it as a dep would loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, laneId, query, reread, full]); // A folder's children are fetched lazily, the first time it is expanded. const toggleDir = (path: string) => { setExpandedDirs((prev) => { const next = new Set(prev); if (!next.delete(path)) next.add(path); return next; }); if (path in treeChildren) return; void api.lanes .gitTree(laneId, path) .then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries })))) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }; const [treeBusy, setTreeBusy] = useState(false); // "Expand all" is ONE read of the whole tree: a request per folder meant a // `git ls-files` per folder, seconds of waiting on a large repo. const expandAllDirs = async () => { const lane = laneId; setTreeBusy(true); try { const { levels } = await api.lanes.gitTreeAll(lane); // A lane switch mid-read must not land lane A's tree in lane B. if (laneNow.current !== lane) return; setTreeChildren(levels); setExpandedDirs(new Set(Object.keys(levels).filter(Boolean))); } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setTreeBusy(false); } }; useEffect(() => { const q = query.trim(); if ((mode !== "browse" && !full) || !q) { setHits(null); return; } // `live` drops an answer that a newer query, a mode change or a lane // switch has overtaken — a slow "main" must not overwrite "mainx". let live = true; const timer = window.setTimeout(() => { void api.lanes .gitLs(laneId, q) .then((r) => live && setHits(r.paths)) .catch(() => live && setHits([])); }, 150); return () => { live = false; window.clearTimeout(timer); }; }, [mode, laneId, query, full]); // The whole-file read is a different endpoint from the diff, so it is its // own fetch — and only while the viewer is actually showing it. useEffect(() => { if (mode !== "file" || !whole || !openFile) return; void api.lanes .gitFile(laneId, openFile, range === "commit" && sha ? sha : "work") .then( forLane(laneId, (r) => setText( r.available ? { path: r.path, lines: r.lines, truncated: r.truncated, binary: r.binary, sha: r.sha, } : null ) ) ) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, [mode, whole, openFile, laneId, range, sha]); useEffect(() => { if (full && mode === "browse") setMode("overview"); }, [full, mode]); // Going full width with a working file already open: straight to the editor. useEffect(() => { if (full && mode === "file" && range !== "commit") setWhole(true); // Only on the switch itself — Whole file must still toggle afterwards. // eslint-disable-next-line react-hooks/exhaustive-deps }, [full]); // A lane switch must not leave the previous lane's file open — its path // almost certainly does not exist in the new one. useEffect(() => { setMode("overview"); setOpenFile(null); setSha(null); setRange("work"); setTreeChildren({}); setExpandedDirs(new Set()); setTreeBusy(false); setBusy(false); setError(null); setCommits(null); setData(null); setDraft(null); setCollapsed(new Set()); // An armed discard names a PATH, not a file in a lane — carried over, one // click would discard the same path in the lane just switched to. setConfirmDiscard(null); // Same for a half-typed commit message: it describes the OTHER lane's work. setMessage(""); returnTo.current = null; pendingScroll.current = null; }, [laneId]); // Restoring has to wait for the list to be TALL enough again: leaving a file // re-fetches the mode it came from, so the first render back is an empty // box that would swallow the scroll. useLayoutEffect(() => { const el = scroller.current; const want = pendingScroll.current; if (mode === "file" || want == null || !el) return; if (el.scrollHeight - el.clientHeight < want) return; el.scrollTop = want; pendingScroll.current = null; }, [mode, data]); const show = (path: string) => { // Opening a file FROM a file (the review list is gone by then) must not // overwrite the list position that is still waiting to be restored. if (mode !== "file") { returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 }; } setOpenFile(path); setContext(CONTEXT_STEPS[0]); // Full width edits by default: a working file opens as the editor, and // Whole file toggles back to its diff. A commit is history, so a diff. setWhole(full && range !== "commit"); setText(null); setDraft(null); setRawEdit(false); setMode("file"); }; const goBack = () => { const back = returnTo.current; pendingScroll.current = back?.scroll ?? 0; returnTo.current = null; setMode(back?.mode ?? (range === "commit" ? "review" : "overview")); setOpenFile(null); setWhole(false); }; // Save the editor over exactly the bytes it opened (`sha`): a file the agent // rewrote meanwhile comes back 409 ESTALE and the draft stays for a retry. const saveDraft = async () => { if (draft === null || !text?.sha) return; const lane = laneId; setBusy(true); setError(null); try { const r = await api.lanes.gitSaveFile(lane, text.path, draft, text.sha); if (laneNow.current !== lane) return; if (r.available) { setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha }); } setDraft(null); refreshGit(); await load(); } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setBusy(false); } }; // An action that resolves after a lane switch belongs to the lane left: its // `load` is the pre-switch one and would take a fresh ticket and paint the // old lane's files here, so everything after the await is dropped. const act = async ( action: "stage" | "unstage" | "discard", path: string | string[], confirm?: boolean ) => { const lane = laneId; setBusy(true); setError(null); try { const paths = Array.isArray(path) ? path : [path]; await api.lanes.gitAction(lane, action, { paths, ...(confirm ? { confirm } : {}) }); if (laneNow.current !== lane) return; setConfirmDiscard(null); refreshGit(); await load(); } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setBusy(false); } }; // resolve / merge-commit / merge-abort: same shape as `act`, other body. const mergeAct = async ( action: "resolve" | "merge-commit" | "merge-abort", body: Parameters[2] = {} ) => { const lane = laneId; setBusy(true); setError(null); try { await api.lanes.gitAction(lane, action, body); if (laneNow.current !== lane) return; setArmedAbort(false); refreshGit(); await load(); } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setBusy(false); } }; const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side }); // One change block, named by its hunk's `@@` line and its index inside that // hunk, matched against a fresh diff at the context this diff was read // with — so the server never applies a stale patch. const actHunk = async ( action: "stage-hunk" | "discard-hunk", path: string, change: LaneDiffChange ) => { const lane = laneId; setBusy(true); setError(null); try { await api.lanes.gitAction(lane, action, { path, hunk: change.hunk.header, block: change.block, context: data?.available ? data.context : undefined, ...(action === "discard-hunk" ? { confirm: true } : {}), }); if (laneNow.current !== lane) return; setArmedHunk(null); refreshGit(); await load(); } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setBusy(false); } }; // Stage / Undo on each change block of a file with unstaged changes. The diff is // HEAD → working tree, so a staged-only file gets none: undoing its tree // would leave the index holding the change. const hunkActions = (f: LaneChangeFile) => range === "work" && f.unstaged && !f.untracked && !f.binary ? (change: LaneDiffChange) => { const key = `${f.path}\n${change.hunk.header}\n${change.block}`; const armed = armedHunk === key; return ( <> ); } : undefined; const commitStaged = async () => { const lane = laneId; setBusy(true); setError(null); try { // `only: "staged"` — the whole point of staging file by file is that the // commit takes the index as it stands, with no `add -A` sweeping the // rest of the tree in behind it. await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" }); if (laneNow.current !== lane) return; setMessage(""); refreshGit(); await load(); } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setBusy(false); } }; const rereadAll = () => { refreshGit(); // The tree cache is dropped whole: after a switch any folder may differ. setTreeChildren({}); setExpandedDirs(new Set()); setReread((n) => n + 1); void load(); }; const toggleCollapsed = (path: string) => setCollapsed((prev) => { const next = new Set(prev); if (!next.delete(path)) next.add(path); return next; }); const dirty = draft !== null && draft !== text?.lines.join("\n"); const files = data?.available ? data.files : []; const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path)); const stagedCount = range === "work" ? files.filter((f) => f.staged && !f.conflicted).length : 0; // What "Stage all" sends: every listed file with a change the index lacks. // Never a conflicted one — staging it would mark it resolved. const toStage = range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : []; const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined; // The armed path left the list (committed, reverted, lane switched): disarm, // so a later reappearance starts from the first click again. useEffect(() => { if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) { setConfirmDiscard(null); } }, [data, files, confirmDiscard]); // A tree or quick-open pick opens the whole file — and, with an edit open, // would drop it, so a dirty draft asks first. const openWhole = (p: string) => { if (dirty && !window.confirm(t("changes.dropEdits"))) return; show(p); setWhole(true); }; // The Files mode's body. At full width it lives in the sidebar instead, so // the Files tab is gone there and the main area never shows it twice. const browsePane = (
    {/* Pinned: the finder is what you reach for after scrolling a long tree, not only from its top. */}
    setQuery(e.target.value)} placeholder={t("changes.quickOpen")} aria-label={t("changes.quickOpen")} className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary" /> {!hits && ( 0 ? t("changes.collapseAllFolders") : t("changes.expandAllFolders") } disabled={treeBusy} onClick={() => expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs() } > {expandedDirs.size > 0 ? ( ) : ( )} )}
    {hits ? (
      {hits.map((p) => (
    • ))} {hits.length === 0 && (
    • {t("changes.noMatches")}
    • )}
    ) : (
    )}
    ); return (
    {/* Full width has room for an explorer: the tree stays beside the file it opened, instead of being a mode you leave to read one. */} {full && ( )}
    {/* ONE wrapping row: where you are (mode) then how you are looking at it (range, layout, context, whole file), refresh last. Each group is its own inline box, so a narrow panel wraps BETWEEN groups — never through one, which is what made the old single row unreadable. */}
    {mode === "file" ? ( ) : (
    {(full ? (["overview", "review", "history"] as const) : (["browse", "overview", "review", "history"] as const) ).map((m) => ( ))}
    )}
    {mode !== "history" && mode !== "browse" && (
    {(["work", "branch"] as const).map((r) => ( ))} {range === "commit" && sha && ( {sha.slice(0, 7)} )}
    {(mode === "file" || mode === "review") && (
    {mode === "review" && ( setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path))) } > {allCollapsed ? ( ) : ( )} )} setLayout(layout === "unified" ? "split" : "unified")} > {layout === "unified" ? ( ) : ( )} {mode === "file" && ( <> {/* Context walks BOTH ways: an expanded diff that cannot be collapsed again leaves the reader re-opening the file to get the tight view back. */} setContext( [...CONTEXT_STEPS].reverse().find((step) => step < context) ?? CONTEXT_STEPS[0] ) } > = LAST_CONTEXT} onClick={() => setContext(CONTEXT_STEPS.find((s) => s > context) ?? context) } > )}
    )} {mode === "file" && ( )}
    )}
    {error && (

    {error}

    )} {git?.available && git.merging && (
    {git.conflicts ? t("changes.mergeBanner", { count: git.conflicts }) : t("changes.mergeReady")}
    )} {/* In single-file mode the DIFF owns the scrolling, so its horizontal bar sits at the bottom of the visible box instead of at the bottom of a 2000-line container nobody has scrolled to yet. Every other mode is a list, and a list scrolls here. */}
    {mode === "history" ? (
      {(commits ?? []).map((c) => (
    • ))} {commits && commits.length === 0 && (
    • {t("changes.noCommits")}
    • )}
    ) : mode === "browse" && !full ? ( browsePane ) : !data ? (

    {t("changes.loading")}

    ) : !data.available ? (

    {t("changes.unavailable")}

    ) : files.length === 0 && mode !== "file" ? (

    {t(`changes.empty.${range}`)}

    ) : mode === "overview" ? ( <> {/* Commit and Stage all sit ABOVE the list and stay pinned while it scrolls: on a 60-file change-set the box at the bottom was a scroll away from every file you had just staged. */} {(stagedCount > 0 || toStage.length > 0) && (
    {/* ONE row: message, commit, then the two bulk index moves. */} {stagedCount > 0 && (
    setMessage(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && message.trim() && !busy) void commitStaged(); }} placeholder={t("changes.commitPlaceholder")} aria-label={t("changes.commitPlaceholder")} className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" />
    )} {toStage.length > 0 && ( )} {stagedCount > 0 && ( )}
    )} {/* The work range splits like VS Code's Source Control: what the next commit takes, then what it leaves behind. A partly staged file sits in both, each row offering only its own index move. */}
    {(range === "work" ? [ { key: "conflicts", rows: files.filter((f) => f.conflicted) }, { key: "staged", rows: files .filter((f) => f.staged && !f.conflicted) .map((f) => ({ ...f, unstaged: false, untracked: false })), }, { key: "unstaged", rows: files .filter((f) => (f.unstaged || f.untracked) && !f.conflicted) .map((f) => ({ ...f, staged: false })), }, ] : [{ key: "all", rows: files }] ).map( (g) => g.rows.length > 0 && (
    {g.key !== "all" && (
    {t(`changes.${g.key}Group`)} {g.rows.length}
    )}
      {g.rows.map((f) => ( void act(...a)} onArm={setConfirmDiscard} onResolve={resolve} /> ))}
    ) )}
    ) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? ( { refreshGit(); void load(); goBack(); }} onEdit={() => { setRawEdit(true); editWanted.current = true; setWhole(true); }} /> ) : mode === "file" ? ( current || (whole && text) ? (
    {openFile} {current && ( )} {whole && text && draft === null && isMarkdown(openFile) && ( )} {/* Only the working file is editable: a commit is history. */} {/* The whole file enters the editor by a click on its text; only the diff, which has no whole text to click, keeps an Edit button. */} {range !== "commit" && (draft === null && whole && text && !text.sha ? ( {t(noEditReason(text))} ) : draft === null && whole ? ( text && !mdPreview && ( {t("changes.clickToEdit")} ) ) : draft === null ? ( ) : ( <> ))}
    {whole && draft !== null ? (