/** * @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) * 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. * @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, Columns2, FileText, FoldVertical, Folder, Minus, Plus, RefreshCw, Rows3, Trash2, UnfoldVertical, } from "lucide-react"; import { api } from "../../lib/api"; import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView"; 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]!; /** Tree reads "expand all" keeps in flight at once. */ const TREE_CONCURRENCY = 4; 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} ); } /** * 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, }: { path: string; depth: number; children: Record; expanded: Set; onToggleDir: (path: string) => void; onOpenFile: (path: string) => void; }) { const entries = children[path]; if (!entries) return null; return ( <> {entries.map((e) => (
  • {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 ( ); } /** 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, }: { f: LaneChangeFile; range: LaneChangeRange; busy: boolean; /** This row's discard has been clicked once and awaits the second click. */ armed: boolean; onOpen: (path: string) => void; onAct: (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void; onArm: (path: string) => void; }) { const { t } = useTranslation(["lanes"]); return (
  • {range === "work" && ( {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); }} > )}
  • ); } export function LaneChangesPanel({ laneId, onNewLane, }: { laneId: number; /** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */ onNewLane?: (branch?: string) => void; }) { 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); 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 } | null>( null ); const [loading, setLoading] = useState(false); const [busy, setBusy] = useState(false); const [confirmDiscard, setConfirmDiscard] = 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(() => { if (mode !== "browse" || query.trim() || "" 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]); // 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); // Which lane `treeChildren` was filled for; the reset effect moves it. const treeLane = useRef(laneId); // Fetches whatever level isn't cached yet, depth-first, so "expand all" // works even on a tree nothing has been opened in. const expandAllDirs = async () => { // A walk is many reads long; a lane switch mid-walk must not land lane A's // tree in lane B, nor start from a cache the switch has not cleared yet. const lane = laneId; setTreeBusy(true); const cache = treeLane.current === lane ? { ...treeChildren } : {}; const toExpand = new Set(expandedDirs); // Level by level, at most TREE_CONCURRENCY reads in flight: one request // per folder all at once is thousands on a monorepo. const visit = async (path: string, next: string[]) => { let entries = cache[path]; if (!entries) { entries = (await api.lanes.gitTree(lane, path)).entries; cache[path] = entries; } for (const e of entries) { if (!e.dir) continue; toExpand.add(e.path); next.push(e.path); } }; try { for (let level = [""]; level.length && laneNow.current === lane; ) { const next: string[] = []; // Workers share one iterator, so each path is taken exactly once. const it = level.values(); await Promise.all( Array.from({ length: TREE_CONCURRENCY }, async () => { for (const path of it) await visit(path, next); }) ); level = next; } if (laneNow.current !== lane) return; setTreeChildren(cache); setExpandedDirs(toExpand); } 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" || !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]); // 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 } : null) ) ) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, [mode, whole, openFile, laneId, range, sha]); // 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({}); treeLane.current = laneId; setExpandedDirs(new Set()); setTreeBusy(false); setBusy(false); setError(null); setCommits(null); setData(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]); setWhole(false); setText(null); setMode("file"); }; // 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, confirm?: boolean) => { const lane = laneId; setBusy(true); setError(null); try { await api.lanes.gitAction(lane, action, { paths: [path], ...(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); } }; 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 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).length : 0; 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]); return (
    {/* Two rows on purpose: WHERE you are (mode, and the refresh that always applies) on top, HOW you are looking at it (range, layout, context, whole file) underneath. One row wrapped mid-group as soon as the panel was narrow, which is every panel beside a console. */}
    {mode === "file" ? ( ) : (
    {(["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}

    )} {/* 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" ? (
    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")}
    • )}
    ) : (
      { show(p); setWhole(true); }} />
    )}
    ) : !data ? (

    {t("changes.loading")}

    ) : !data.available ? (

    {t("changes.unavailable")}

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

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

    ) : mode === "overview" ? ( <>
      {files.map((f) => ( void act(...a)} onArm={setConfirmDiscard} /> ))}
    {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" />
    )} ) : mode === "file" ? ( current || (whole && text) ? (
    {openFile} {current && ( )}
    {whole ? ( text ? ( ) : (

    {t("changes.loading")}

    ) ) : current ? ( ) : null}
    ) : (

    {t("changes.fileGone")}

    ) ) : (
    {files.map((f) => { const shut = collapsed.has(f.path); return (
    {/* The whole header folds the file; only the PATH opens it. A header that opened the file on any click made folding a target-practice exercise against one chevron. */}
    toggleCollapsed(f.path)} onKeyDown={(e) => { // Only the header's own keys: Enter on the path button // inside bubbles here and must open, not fold. if (e.target !== e.currentTarget) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleCollapsed(f.path); } }} aria-expanded={!shut} aria-label={ shut ? t("changes.expandFile", { path: f.path }) : t("changes.collapseFile", { path: f.path }) } className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${ shut ? "" : "border-b border-border-light" }`} > {f.untracked ? "A" : f.status.trim()[0] || "M"}
    {!shut && }
    ); })}
    )} {data?.available && data.truncated && mode !== "history" && mode !== "browse" && (

    {t("changes.truncated")}

    )}
    ); } export default LaneChangesPanel;