/** * @file The Changes panel: the lane's code beside its console, in the five * shapes a review actually needs — * * overview — every changed file, in a folder tree (JetBrains' commit view), * 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 * working file is always the editor (`DiffEditor`, diffed * against HEAD / the branch's merge base, unified or side by * side): Changes folds the unchanged lines away, Whole file * shows them; any other file opens on its read-only diff; a * whole markdown file can also be read rendered (Preview), and an * image file is shown as the picture (`GET …/git/raw`) * history — recent commits; opening one shows that commit's diff * browse — quick-open by path, or the repo's tracked file tree (the * mode switcher's Files button; at full width the sidebar) * * 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. * * A diff line's number opens a comment box (LaneDiffView `onComment`); the * comment is pasted, as `: — `, into the workspace's pane * focused last (`lib/panePaste.ts`) and sent only when the user presses Enter; * a toast says which tab took it, and a console not on screen takes nothing. * * An unsaved whole-file edit is never dropped silently: Back, another file, * Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or * reloading the browser tab asks the browser's own question, and * `onDirtyChange` tells the Workspace, which asks before closing the panel, * switching its tab, or moving it to another workspace or session. Two-click * actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`). * @author Nguyễn Ngọc Trí Vĩ */ import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsDownUp, ChevronsUpDown, Check, Code2, Columns2, Eye, EyeOff, FileText, FoldVertical, Folder, Minus, Pencil, Plus, RefreshCw, Rows3, Sparkles, Trash2, UnfoldVertical, } from "lucide-react"; import { api, gitTarget } from "../../lib/api"; import { pasteToLane } from "../../lib/panePaste"; import { confirmDiscard as askDiscard } from "../../lib/confirm"; import { copyText } from "../../lib/clipboard"; import { isSubmitEnter } from "../../lib/keys"; import { toast } from "../../lib/toast"; import { useArmed } from "../../hooks/useArmed"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; import { LaneDiffView, LaneFileView, langFor, type DiffLayout, type LaneDiffChange, } from "./LaneDiffView"; import { LaneConflictView } from "./LaneConflictView"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { commitGraph, type GraphRow } from "../../lib/commitGraph"; 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} ); } /** What the viewer shows as a picture (`readImageAt`'s list on the server). */ const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i; // CodeMirror is loaded only once a file is edited. const DiffEditor = lazy(() => import("./DiffEditor")); /** A file that opens straight in the editor: a working file with text — not a * commit's, an image, a binary, a deleted file or a merge conflict (which * opens in the conflict view). */ function editableFile(range: LaneChangeRange, path: string, f?: LaneChangeFile) { if (range === "commit" || IMAGE_FILE.test(path)) return false; return !f || (!f.binary && !f.conflicted && !f.status.includes("D")); } /** An image file, shown as itself; a broken one (deleted, too big) says so. */ function ImageView({ src, path }: { src: string; path: string }) { const { t } = useTranslation("lanes"); const [failed, setFailed] = useState(false); useEffect(() => setFailed(false), [src]); return (
{failed ? (

{t("changes.imageFailed")}

) : ( {path} setFailed(true)} className="max-h-full max-w-full object-contain" /> )}
); } /** 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, onMenu, active, kept, }: { path: string; depth: number; children: Record; /** Paths on the never-stage list: drawn orange, whatever their git status. */ kept?: Set; expanded: Set; onToggleDir: (path: string) => void; onOpenFile: (path: string) => void; /** Right-click on an entry. */ onMenu?: (e: React.MouseEvent, entry: LaneTreeEntry) => 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; const isKept = !e.dir && !!kept?.has(e.path); 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, kept = false, onKeep, }: { f: LaneChangeFile; busy: boolean; armed: boolean; onAct: FileAct; onArm: (path: string) => void; /** On the never-stage list: Stage all and a folder's stage skip it. */ kept?: boolean; onKeep?: (path: string) => void; }) { const { t } = useTranslation(["lanes"]); return ( {f.staged ? ( onAct("unstage", f.path)} > ) : null} {f.unstaged || f.untracked ? ( onAct("stage", f.path)} > ) : null} {onKeep && (kept || f.unstaged || f.untracked) ? ( onKeep(f.path)} > {kept ? : } ) : 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, onMenu, name, depth = 0, kept = false, onKeep, }: { f: LaneChangeFile; kept?: boolean; onKeep?: (path: string) => void; /** In the folder tree: the file's own name, and how deep it sits. */ name?: string; depth?: number; range: LaneChangeRange; onResolve: Resolve; onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void; 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 (
  • onMenu(e, f)} > {range === "work" && (f.conflicted ? ( ) : ( ))}
  • ); } /** One folder of the overview's change tree: its sub-folders (each a * collapsible row with its file count), then its files. */ function ChangeTreeNodes({ dir, depth, group, collapsed, onToggle, renderFile, folderAction, }: { dir: ChangeTreeDir; depth: number; /** The overview group, so the same folder folds apart in each. */ group: string; collapsed: Set; onToggle: (key: string) => void; renderFile: (f: LaneChangeFile, name: string, depth: number) => React.ReactNode; /** The folder row's own action, e.g. stage every file in it. */ folderAction?: (files: LaneChangeFile[]) => React.ReactNode; }) { const { t } = useTranslation(["lanes"]); return ( <> {dir.dirs.map((d) => { const key = `${group}:${d.path}`; const open = !collapsed.has(key); return (
  • {folderAction?.(treeFiles(d))}
    {open && (
    )}
  • ); })} {dir.files.map(({ name, file }) => renderFile(file, name, depth))} ); } /** History's page size, and the most the server reads (`listCommits`). */ const HISTORY_PAGE = 50; const HISTORY_MAX = 200; // The history's commit graph: one fixed-height row per commit so its lanes // join up row to row. Mid-tone hues that read on dark and light themes alike. const GRAPH_ROW = 34; const GRAPH_COL = 12; const GRAPH_COLORS = [ "#3b82f6", "#22c55e", "#f59e0b", "#a855f7", "#ef4444", "#14b8a6", "#ec4899", "#84cc16", ]; function GraphCell({ row, width }: { row: GraphRow; width: number }) { const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2; const y = (f: number) => f * GRAPH_ROW; const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length]; return ( {row.segs.map((g, i) => { const ym = (y(g.y1) + y(g.y2)) / 2; return ( ); })} ); } export function LaneChangesPanel({ laneId, session, onNewLane, full = false, wide = full, onDirtyChange, }: { laneId: number; /** The whole-file editor holds unsaved text (true) or not (false). */ onDirtyChange?: (dirty: boolean) => void; /** Show and act on that session's own worktree (`claude --worktree`) * instead of the workspace's folder. The Workspace keys the panel on it, * so a different session is a fresh panel. */ session?: string; /** 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; /** Half the width or more: diffs and the editor open side by side. */ wide?: 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, session); // Every git read and write below goes to the same tree as those facts. const target = gitTarget(laneId, session); // A diff line's comment goes into this workspace's pane, as a paste. const comment = useCallback( (text: string) => { const runId = pasteToLane(laneId, text); if (runId) { const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0); toast.success(t("changes.commentPasted", { tab: slot + 1 })); } return runId !== null; }, [laneId, t] ); // 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); // An image opens as a picture, never as text or a binary diff. const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null; const [sha, setSha] = useState(null); const [context, setContext] = useState(CONTEXT_STEPS[0]); // Side by side wherever there is room for two columns; a resize starts over // from that default, a click on the toggle overrides it until then. const [layout, setLayout] = useState(wide ? "split" : "unified"); useEffect(() => setLayout(wide ? "split" : "unified"), [wide]); 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); // The text the editor diffs against: the file at HEAD (`work`) or at the // branch's merge base; "" for a file that is new there. const [baseText, setBaseText] = useState(null); const startEdit = () => { if (!text?.sha) return; setDraft(text.lines.join("\n")); }; useEffect(() => { if (!editWanted.current || !text) return; editWanted.current = false; if (text.sha) startEdit(); // 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); // Two-click arming that disarms by itself after 3 s: the armed path, the // armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort. const discardArm = useArmed(); const confirmDiscard = discardArm.armed; const setConfirmDiscard = (path: string | null) => path ? discardArm.arm(path) : discardArm.disarm(); const abortArm = useArmed(); const armedAbort = abortArm.armed !== null; const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm()); // 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}\n${block}`. const hunkArm = useArmed(); const armedHunk = hunkArm.armed; const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm()); // 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()); // The overview tree's folded groups and folders (`:`); // everything starts open. const [treeCollapsed, setTreeCollapsed] = useState>(new Set()); const toggleTreeDir = (key: string) => setTreeCollapsed((prev) => { const next = new Set(prev); if (!next.delete(key)) next.add(key); return next; }); // Files the user never wants staged (a local note, a scratch folder): Stage // all and a folder's stage skip them; git is never told — nothing is written // to the repo. A viewer preference per workspace, like the panel's size. const neverStageKey = `agenthub.changes.neverStage.${laneId}`; const [neverStage, setNeverStage] = useState>(new Set()); useEffect(() => { try { const saved = JSON.parse(window.localStorage.getItem(neverStageKey) || "[]"); setNeverStage( new Set(Array.isArray(saved) ? saved.filter((p) => typeof p === "string") : []) ); } catch { setNeverStage(new Set()); } }, [neverStageKey]); const toggleNeverStage = (path: string) => setNeverStage((prev) => { const next = new Set(prev); if (!next.delete(path)) next.add(path); try { window.localStorage.setItem(neverStageKey, JSON.stringify([...next])); } catch { /* kept for this visit only */ } return next; }); // 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 [suggesting, setSuggesting] = useState(false); 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(target, { 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. // History reads HISTORY_PAGE commits, and Load more asks for the next page // (up to the server's 200). const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE); const [historyLoading, setHistoryLoading] = useState(false); useEffect(() => { if (mode !== "history") return; setHistoryLoading(true); void api.lanes .gitLog(target, { limit: historyLimit }) .then(forLane(laneId, (r) => setCommits(r.commits))) .catch((err) => setError(err instanceof Error ? err.message : String(err))) .finally(() => laneNow.current === laneId && setHistoryLoading(false)); }, [mode, laneId, reread, historyLimit]); // Re-run when a lane switch empties the cache too: the full-width sidebar // stays across the switch, so the mode alone never changes. const treeRootLoaded = "" in treeChildren; useEffect(() => { const wanted = full || (mode === "browse" && !query.trim()); if (!wanted || treeRootLoaded) return; void api.lanes .gitTree(target, "") .then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))) .catch((err) => setError(err instanceof Error ? err.message : String(err))); // `treeChildren` itself is the cache this fills — a dep on it would loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, laneId, query, reread, full, treeRootLoaded]); // 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(target, 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(target); // 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(target, q) .then((r) => live && setHits(r.paths)) .catch(() => live && setHits([])); }, 150); return () => { live = false; window.clearTimeout(timer); }; }, [mode, laneId, query, full]); // The open file's entry in the change list, and whether it is edited in // place: a working file with text opens as the editor in both views. const openEntry = data?.available && openFile ? data.files.find((f) => f.path === openFile) : undefined; const editable = mode === "file" && !!openFile && editableFile(range, openFile, openEntry); // 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 || editable) || !openFile) return; void api.lanes .gitFile(target, 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, editable, openFile, laneId, range, sha]); // The editor's original side, read beside the working file. A file that is // not there (new since) diffs against nothing: every line is added. const mergeBase = data?.available ? (data.mergeBase ?? null) : null; useEffect(() => { if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return; const rev = range === "branch" && mergeBase ? mergeBase : "HEAD"; void api.lanes.gitFile(target, openFile, rev).then( forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")), forLane(laneId, () => setBaseText("")) ); }, [mode, whole, editable, openFile, laneId, range, mergeBase]); useEffect(() => { if (full && mode === "browse") setMode("overview"); }, [full, mode]); // 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]); // After the lane reset above: a workspace opens on its changes. // Leaving the open file this way (the Workspace asked first when it was // edited) drops it whole: a draft left behind would keep `dirty` true. useEffect(() => { if (full) return; setMode("overview"); setOpenFile(null); setWhole(false); setDraft(null); }, [laneId, full]); // 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]); // Every way out of an open, edited file asks once; nothing else does. const dirtyRef = useRef(false); const confirmDrop = async () => !dirtyRef.current || (await askDiscard()); const showNow = (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]); // A file opens on its Changes. A working file is the editor folded to its // changes (each block with a Revert); Whole file unfolds it. Anything else // — a commit, a binary, a deleted file — is the read-only diff. const f = data?.available ? data.files.find((x) => x.path === path) : undefined; editWanted.current = editableFile(range, path, f); setWhole(false); setText(null); setBaseText(null); setDraft(null); setRawEdit(false); setMode("file"); }; const show = (path: string) => { void confirmDrop().then((ok) => ok && showNow(path)); }; const goBackNow = () => { const back = returnTo.current; pendingScroll.current = back?.scroll ?? 0; returnTo.current = null; setMode(back?.mode ?? (range === "commit" ? "review" : "overview")); setOpenFile(null); setWhole(false); setDraft(null); }; const goBack = () => { void confirmDrop().then((ok) => ok && goBackNow()); }; // 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(target, 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 }); } // Saved, still editing: the editor stays on the text just written. setDraft(r.available ? r.lines.join("\n") : 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(target, 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(target, 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 }); const fileCtx = useContextMenu(); const treeCtx = useContextMenu(); const commitCtx = useContextMenu(); const viewCommit = (sha: string) => { setSha(sha); setRange("commit"); setMode("review"); }; const copy = (text: string) => void copyText(text); // A menu item: close its menu, then act. const pick = (close: () => void, fn: () => void) => () => { close(); fn(); }; const menuFile = fileCtx.menu?.item; const menuArmed = !!menuFile && confirmDiscard === menuFile.path; const menus = ( <> {fileCtx.menu && menuFile && ( show(menuFile.path))}> {t("changes.openFile", { path: menuFile.path.split("/").pop() })} {range === "work" && menuFile.conflicted && ( <> resolve(menuFile.path, "ours"))} > {t("changes.takeOurs")} resolve(menuFile.path, "theirs"))} > {t("changes.takeTheirs")} resolve(menuFile.path, "done"))} > {t("changes.markResolved")} )} {range === "work" && !menuFile.conflicted && ( <> {(menuFile.unstaged || menuFile.untracked) && ( void act("stage", menuFile.path))} > {t("changes.stage")} )} {menuFile.staged && ( void act("unstage", menuFile.path))} > {t("changes.unstage")} )} toggleNeverStage(menuFile.path))}> {t(neverStage.has(menuFile.path) ? "changes.allowStage" : "changes.neverStage")} menuArmed ? pick(fileCtx.close, () => void act("discard", menuFile.path, true))() : setConfirmDiscard(menuFile.path) } > {menuArmed ? t("changes.discardConfirm") : t("changes.discard")} )} copy(menuFile.path))}> {t("changes.copyPath")} )} {treeCtx.menu && ( {!treeCtx.menu.item.dir && ( void openWhole(treeCtx.menu!.item.path))}> {t("changes.openFile", { path: treeCtx.menu.item.name })} )} copy(treeCtx.menu!.item.path))}> {t("changes.copyPath")} )} {commitCtx.menu && ( viewCommit(commitCtx.menu!.item.sha))}> {t("changes.viewCommit")} copy(commitCtx.menu!.item.sha))}> {t("changes.copySha")} copy(commitCtx.menu!.item.subject))}> {t("changes.copySubject")} )} ); // 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(target, 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(target, "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); } }; // ~10 s: the server runs `claude -p` on the staged diff. Its own flag, not // `busy`, so staging stays usable while it thinks. const suggestMessage = async () => { const lane = laneId; setSuggesting(true); try { const { message: suggested } = await api.lanes.suggestCommitMessage(target); if (laneNow.current === lane) setMessage(suggested); } catch (err) { if (laneNow.current === lane) toast.error(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setSuggesting(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"); dirtyRef.current = dirty; useUnsavedGuard(dirty); const dirtyChangeRef = useRef(onDirtyChange); dirtyChangeRef.current = onDirtyChange; useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]); // Unmounted with an edit open (the panel closed or moved on, after the // Workspace asked): nothing is dirty any more. useEffect(() => () => dirtyChangeRef.current?.(false), []); 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 && !neverStage.has(f.path)) : []; 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 a changed file on its Changes like any // other, and an unchanged one — no diff to show — as the whole file, in the // editor. With an edit open it would drop it, so a dirty draft asks first. const openWhole = async (p: string) => { if (!(await confirmDrop())) return; showNow(p); // The overview's list, or — with one file open, the list is that file — // the working-copy facts' (capped) list of changed paths. const changed = (git?.available ? git.files : []).map((f) => f.path); if ([...files.map((f) => f.path), ...changed].includes(p)) return; editWanted.current = editableFile(range, p); setWhole(true); }; // The Files mode's body. At full width it lives in the sidebar instead, so // the Files button 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")}
    • )}
    ) : (
      void openWhole(p)} onMenu={treeCtx.open} active={full && mode === "file" ? openFile : null} kept={neverStage} />
    )}
    ); return (
    {menus} {/* 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" ? ( ) : (
    {/* Files is the whole repo's tree; at full width it is the sidebar already, so no button. */} {(full ? (["overview", "review", "history"] as const) : (["overview", "browse", "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" && (
    {([false, true] as const).map((w) => ( ))}
    )}
    )}
    {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" ? (
      {(() => { const list = commits ?? []; const graph = commitGraph(list); return list.map((c, i) => (
    • )); })()} {commits === null && (
    • {t("changes.loading")}
    • )} {commits && commits.length === 0 && (
    • {t("changes.noCommits")}
    • )} {commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
    • )}
    ) : 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 (isSubmitEnter(e) && 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" && ( )} {!treeCollapsed.has(`${g.key}:`) && (
      {/* Folders like JetBrains' commit view: a long change-set reads by area, not as one flat list. */} ( void act(...a)} onArm={setConfirmDiscard} onResolve={resolve} onMenu={fileCtx.open} kept={neverStage.has(f.path)} onKeep={toggleNeverStage} /> )} folderAction={ g.key === "staged" || g.key === "unstaged" ? (rows) => ( void act( g.key === "staged" ? "unstage" : "stage", rows .filter( (f) => g.key === "staged" || !neverStage.has(f.path) ) .map((f) => f.path) ) } > {g.key === "staged" ? ( ) : ( )} ) : undefined } />
    )}
    ) )}
    ) : 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) || openImage ? (
    {openFile} {current && ( )} {whole && text && !dirty && isMarkdown(openFile) && ( )} {/* Only the working file is editable: a commit is history. */} {/* A working file opens in the editor; the read-only diff (Whole file off) keeps an Edit button back into it. */} {range !== "commit" && !openImage && (draft === null && whole && text && !text.sha ? ( {t(noEditReason(text))} ) : draft === null && whole ? null : draft === null ? ( ) : ( <> ))}
    {openImage ? ( ) : !mdPreview && (whole || editable) && (draft !== null || (editable && text === null)) ? ( // The editor, once both texts are in: not the diff first. draft === null || (baseText === null && range !== "commit") ? (

    {t("changes.loading")}

    ) : ( {t("changes.loading")}

    } > { if (!busy) void saveDraft(); }} />
    ) ) : whole ? ( text && mdPreview ? (
    ) : 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)} onContextMenu={(e) => fileCtx.open(e, f)} 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 }) } data-sticky-top="" className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-2 px-2 py-1 hover:bg-surface-3 ${ shut ? "" : "border-b border-border-light" }`} > {f.untracked ? "A" : f.status.trim()[0] || "M"} {range === "work" && ( // The header folds on click; its buttons must not. e.stopPropagation()}> void act(...a)} onArm={setConfirmDiscard} /> )}
    {!shut && ( )}
    ); })}
    )} {data?.available && data.truncated && mode !== "history" && mode !== "browse" && (

    {t("changes.truncated")}

    )}
    ); } export default LaneChangesPanel;