/** * @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): * Changes and Unversioned Files, each file, folder and group * with a checkbox, its +/- counts and its discard action; 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, each block with the diff's Undo / Keep while nothing * is unsaved): 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); * a row dragged onto a prompt box attaches it as `@path` * * 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). * * A file can also be opened from outside the panel (`openRequest`: a chat's * "Edited N files" card), the same way a click in the tree opens it. * * 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, cherry-pick or revert (issue #45) a banner offers * **Complete** (only once no file is conflicted) and **Abort** (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. * * The commit is JetBrains' (PhpStorm's Commit window without its staging * area): Commit takes exactly the TICKED files' working-tree content * (`paths`, `git commit --only`), whatever the index holds. A tracked change * arrives ticked, an Unversioned one unticked, a never-commit one (this * browser's list) never by default; Commit and Push pushes after it. A file's * menu adds Commit File…, Jump to Source, Copy as Patch, Add to VCS and Add to * .gitignore (`ignore`, untracked only). While a merge is open the box is * hidden: a partial commit cannot finish one, Complete does. * * History rewrites through its commit menu: Revert (any commit), Undo commit * (the top one; the server refuses a pushed one) and, while another branch's * history is shown, Cherry-pick onto this branch. The commit row's Amend box * folds the ticked files into the last commit (none ticked: rewords it). All * four ask first (`confirmDialog`). * * "vs base" compares against the lane's base, else the repo's default branch * (`git.base`), and is hidden when there is none or the lane IS on it. * * 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. * A selection in the editor offers Add to Chat, which pastes ` @#La-b` * (Claude Code's line-range mention — without the `L` it attaches the whole * file) the same way. * * 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) 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, ChevronUp, ChevronsDownUp, ChevronsUpDown, Check, Code2, Columns2, Eye, 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 { confirmDialog, 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 { useSpin } from "../../hooks/useSpin"; import { useUnsavedGuard } from "../../hooks/useUnsavedGuard"; import type { BlockAction, EditorBlock } from "./DiffEditor"; import { LaneDiffView, LaneFileView, changeBlocks, 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 { dragProjectPath } from "../../lib/promptAttach"; import { commitGraph, type GraphRow } from "../../lib/commitGraph"; import type { LaneChangeFile, LaneChangeRange, LaneChanges, LaneBlame, LaneCommit, LaneDiffHunk, 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. */ /** JetBrains' VCS colours: unversioned orange, added green, deleted grey, * modified (and renamed) blue. */ function statusClass(status: string, untracked: boolean) { if (untracked) return "text-kept"; const code = status.trim()[0]; if (code === "A") return "text-status-success"; if (code === "D") return "text-fg-muted"; return "text-diff-mod"; } /** `+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 + JetBrains' * colour (U unversioned orange, A added green, D deleted grey, R/M blue, * ! conflicted red). */ 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-kept bg-kept/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-fg-muted" }; if (code.includes("R")) return { letter: "R", tone: "text-diff-mod bg-diff-mod/10" }; return { letter: "M", tone: "text-diff-mod bg-diff-mod/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, }: { path: string; depth: number; children: Record; 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; 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"; /** The git change block an editor block is, by its line span on both sides; * none when the editor's diff and git's split the change differently. */ export function gitChangeFor(f: LaneChangeFile, b: EditorBlock): LaneDiffChange | undefined { return (f.hunks ?? []).flatMap(changeBlocks).find((c) => { const lines = c.hunk.lines.slice(c.firstLine, c.lastLine + 1); const del = lines.filter((l) => l.type === "del"); const add = lines.filter((l) => l.type === "add"); return ( del.length === b.oldCount && add.length === b.newCount && (!del.length || del[0]!.oldLine === b.oldFrom) && (!add.length || add[0]!.newLine === b.newFrom) ); }); } 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, vcs = false, }: { f: LaneChangeFile; busy: boolean; armed: boolean; onAct: FileAct; onArm: (path: string) => void; /** The overview's JetBrains rows: no index moves, only Add to VCS for an * untracked file — the checkbox is what picks a file for the commit. */ vcs?: boolean; }) { const { t } = useTranslation(["lanes"]); return ( {f.staged && !vcs ? ( onAct("unstage", f.path)} > ) : null} {(vcs ? f.untracked : 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`). Armed, the icon becomes a red word, so the second click is asked for where the eye already is. */} {armed ? ( ) : ( onArm(f.path)} > )} ); } /** A commit checkbox: ticked, unticked or — for a folder or a group with * some files ticked — indeterminate, which a click clears. */ function TickBox({ state, label, onChange, }: { state: boolean | "some"; label: string; onChange: (on: boolean) => void; }) { return ( { if (el) el.indeterminate = state === "some"; }} checked={state === true} onChange={() => onChange(state === false)} onClick={(e) => e.stopPropagation()} aria-label={label} title={label} className="h-3 w-3 shrink-0 cursor-pointer self-center" /> ); } /** A folder's or group's TickBox state from its files' own. */ function tickState( files: LaneChangeFile[], fileTick: (path: string) => boolean | "some" ): boolean | "some" { const states = files.map((f) => fileTick(f.path)); if (states.every((x) => x === true)) return files.length ? true : false; return states.every((x) => x === false) ? false : "some"; } const keysOf = new WeakMap>(); /** Each change block of a file, keyed by its content (its -/+ lines) and how * many identical blocks came before it — so an unticked block stays * unticked across a re-read at another context or an edit elsewhere in the * file, which both move its hunk header. */ export function blockKeys(hunks: LaneDiffHunk[]): Map { const hit = keysOf.get(hunks); if (hit) return hit; const seen = new Map(); const out = new Map(); for (const c of hunks.flatMap(changeBlocks)) { const sig = c.hunk.lines .slice(c.firstLine, c.lastLine + 1) .map((l) => `${l.type === "add" ? "+" : "-"}${l.text}`) .join("\n"); const n = seen.get(sig) ?? 0; seen.set(sig, n + 1); out.set(c, `${n}\n${sig}`); } keysOf.set(hunks, out); return out; } /** One file's diff as a unified patch (Copy as Patch). ponytail: git's * "\ No newline at end of file" markers are not in the parsed hunks, so a * file missing its last newline applies with one; read `git diff` raw if * that ever matters. */ function toPatch(f: LaneChangeFile): string { const added = f.untracked || f.status.includes("A"); const deleted = f.status.includes("D"); return [ `diff --git a/${f.path} b/${f.path}`, `--- ${added ? "/dev/null" : `a/${f.path}`}`, `+++ ${deleted ? "/dev/null" : `b/${f.path}`}`, ...(f.hunks ?? []).flatMap((h) => [ h.header, ...h.lines.map((l) => `${l.type === "add" ? "+" : l.type === "del" ? "-" : " "}${l.text}`), ]), "", ].join("\n"); } 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, ticked, onTick, }: { f: LaneChangeFile; /** In the commit: the row's checkbox (absent = no checkbox). */ ticked?: boolean | "some"; onTick?: (path: string, on: boolean) => 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)} > {onTick && ( onTick(f.path, on)} /> )} {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, folderTick, }: { 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 checkbox, before its name. */ folderTick?: (files: LaneChangeFile[], path: string) => React.ReactNode; }) { const { t } = useTranslation(["lanes"]); return ( <> {dir.dirs.map((d) => { const key = `${group}:${d.path}`; const open = !collapsed.has(key); return (
  • {folderTick?.(treeFiles(d), d.path)}
    {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, source, onNewLane, full = false, wide = full, onDirtyChange, openRequest = null, }: { laneId: number; /** Open this file (relative to the panel's folder); a new `nonce` asks again. */ openRequest?: { path: string; nonce: number } | null; /** 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; /** Show and act on that extra folder's repo instead (`lane_sources` id); * wins over `session`, and is keyed on like it. */ source?: 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; /** 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, source); // A merge, cherry-pick or revert is open: no new one starts from history. const opOpen = !!(git?.available && git.merging); // Every git read and write below goes to the same tree as those facts. const target = gitTarget(laneId, session, source); // 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; }); // 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(""); // The commit row amends the last commit instead of making a new one. const [amend, setAmend] = useState(false); // The files the commit takes (JetBrains' checkboxes). A path listed for the // first time arrives with its default; `seenPaths` is the last full list, // so a poll never re-ticks a file the user unticked. const [ticked, setTicked] = useState>(new Set()); // A ticked file's change blocks left OUT of the commit (each block's own // checkbox in the diff), by `blockKeys`; absent = the whole file. const [excluded, setExcluded] = useState>>(new Map()); // The last full list (overview or review), in order: Next/Previous file // walks it while one file is open and `data` holds that file alone. const [listed, setListed] = useState([]); // History of one file (Show History), not the branch; null = the branch. const [historyFile, setHistoryFile] = useState(null); // Annotate: who last changed each line of the open file. const [annotate, setAnnotate] = useState(false); const [blame, setBlame] = useState | null>(null); const editorNav = useRef<((dir: 1 | -1) => void) | null>(null); const seenPaths = useRef | null>(null); const messageRef = useRef(null); // History of another branch (where Cherry-pick finds its commits); null = // this branch's own. const [historyRef, setHistoryRef] = useState(null); const [historyBranches, setHistoryBranches] = 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( () => document.visibilityState === "visible" && 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, ...(historyRef ? { ref: historyRef } : {}), ...(historyFile ? { file: historyFile } : {}), }) .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, historyRef, historyFile]); useEffect(() => { if (mode !== "history") return; void api.lanes .gitBranches(target) .then(forLane(laneId, (r) => setHistoryBranches(r.branches))) .catch(() => setHistoryBranches([])); }, [mode, laneId, reread]); // 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(""); setAmend(false); setTicked(new Set()); setExcluded(new Map()); setListed([]); setHistoryFile(null); seenPaths.current = null; setHistoryRef(null); 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, inRange: LaneChangeRange = range) => { // 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(inRange, 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)); }; // A file named from outside the panel (a chat's "Edited N files" card). useEffect(() => { if (openRequest) show(openRequest.path); // eslint-disable-next-line react-hooks/exhaustive-deps -- once per request }, [openRequest?.nonce]); // A file's history (Show History): the commits that touched it. const showHistory = (path: string) => { void confirmDrop().then((ok) => { if (!ok) return; setHistoryFile(path); setHistoryRef(null); setCommits(null); setMode("history"); }); }; // A commit picked from a file's history: that file, as that commit changed it. const openAtCommit = (path: string, commitSha: string) => { setSha(commitSha); setRange("commit"); showNow(path, "commit"); }; // Next / Previous file, in the list the file was opened from. const fileAt = listed.indexOf(openFile ?? ""); const stepFile = (dir: 1 | -1) => { const next = listed[fileAt + dir]; if (fileAt >= 0 && next) show(next); }; // F7 / Shift+F7: the next / previous difference — the editor's own chunks, // else the diff rows each change block starts on (`data-change-index`). // ponytail: a windowed diff (500+ rows) keeps off-screen rows out of the // DOM, so a jump there stops at the last rendered block. const stepDiff = (dir: 1 | -1) => { if (mode === "file" && editorNav.current) return editorNav.current(dir); const root = scroller.current; if (!root) return; const top = root.getBoundingClientRect().top + 48; const marks = [...root.querySelectorAll("[data-change-index]")].map((el) => ({ el, y: el.getBoundingClientRect().top, })); const hit = dir === 1 ? marks.find((m) => m.y > top + 1) : [...marks].reverse().find((m) => m.y < top - 1); hit?.el.scrollIntoView?.({ block: "center" }); }; const toggleAnnotate = async () => { if (annotate || !openFile) return setAnnotate(false); setAnnotate(true); const lane = laneId; try { const r = await api.lanes.gitBlame(target, openFile); if (laneNow.current !== lane) return; if (!r.available) { setAnnotate(false); toast.error(t("changes.noBlame")); return; } setBlame(r); } catch (err) { if (laneNow.current !== lane) return; setAnnotate(false); toast.error(err instanceof Error ? err.message : String(err)); } }; useEffect(() => { setAnnotate(false); setBlame(null); }, [openFile, laneId]); 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" | "ignore", 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; // Add to VCS puts the file in the commit, as JetBrains does. if (action === "stage") setTicked((prev) => new Set([...prev, ...paths])); 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 }); // Revert / cherry-pick / undo from the history's commit menu. A conflict is // an operation that STARTED: the overview is where it gets resolved. const historyAct = async (action: "cherry-pick" | "revert" | "undo-commit", c: LaneCommit) => { // Every one of the three rewrites this branch's history, so each asks. const branch = git?.available ? git.branch : "HEAD"; const ask = action === "undo-commit" ? { title: t("changes.undoCommitTitle"), message: t("changes.undoCommitBody", { short: c.short, subject: c.subject }), confirmLabel: t("changes.undoCommitConfirm"), } : action === "revert" ? { title: t("changes.revertCommit"), message: t("changes.revertConfirm", { short: c.short, subject: c.subject, branch }), confirmLabel: t("changes.revertCommit"), destructive: false, } : { title: t("changes.cherryPick", { branch }), message: t("changes.cherryPickConfirm", { short: c.short, subject: c.subject, from: historyRef, branch, }), confirmLabel: t("changes.cherryPick", { branch }), destructive: false, }; if (!(await confirmDialog(ask))) return; const lane = laneId; setBusy(true); setError(null); try { const r = await api.lanes.gitAction( target, action, // The commit the dialog named: the server refuses an undo once HEAD // is another one (ESTALE) — an agent may have committed meanwhile. action === "undo-commit" ? { confirm: true, sha: c.sha } : { sha: c.sha } ); if (laneNow.current !== lane) return; if (r.conflicts?.length) { toast.error(t("changes.opConflicts", { op: action, count: r.conflicts.length })); } else if (action !== "undo-commit") { toast.success(t("changes.opDone", { op: action })); } // Conflicts and an undone commit's staged changes are both work-range // facts; a clean pick or revert is best seen in this branch's history. setHistoryRef(null); if (action === "undo-commit" || r.conflicts?.length) { setRange("work"); setMode("overview"); } void rereadAll(); } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setBusy(false); } }; 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 && ( {range === "work" && !menuFile.conflicted && !opOpen && ( <> commitFile(menuFile.path))} > {t("changes.commitFile")} )} show(menuFile.path))}> {t("changes.openFile", { path: menuFile.path.split("/").pop() })} {!menuFile.status.includes("D") && ( void jumpToSource(menuFile.path))}> {t("changes.jumpToSource")} )} showHistory(menuFile.path))}> {t("changes.showHistory")} {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.untracked && ( <> void act("stage", menuFile.path))} > {t("changes.addToVcs")} void act("ignore", menuFile.path))} > {t("changes.addToGitignore")} )} {/* The index is not what Commit takes any more, but a hunk staged from a review still sits there: the way back out. */} {menuFile.staged && ( void act("unstage", menuFile.path))} > {t("changes.unstage")} )} 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")} {!menuFile.binary && ( void copyPatch(menuFile.path))}> {t("changes.copyPatch")} )} void runReread())}> {t("changes.refresh")} )} {treeCtx.menu && ( {!treeCtx.menu.item.dir && ( <> void openWhole(treeCtx.menu!.item.path))} > {t("changes.openFile", { path: treeCtx.menu.item.name })} showHistory(treeCtx.menu!.item.path))}> {t("changes.showHistory")} )} 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")} {historyRef ? ( void historyAct("cherry-pick", commitCtx.menu!.item) )} > {t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })} ) : ( <> void historyAct("revert", commitCtx.menu!.item) )} > {t("changes.revertCommit")} {commits?.[0]?.sha === commitCtx.menu.item.sha && ( void historyAct("undo-commit", commitCtx.menu!.item) )} > {t("changes.undoCommit")} )} )} )} ); // 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(); // Undone under the open editor: it shows the file as it is now (the // bars only exist while nothing is unsaved, so no draft is lost). if (action === "discard-hunk" && text?.path === path) { const r = await api.lanes.gitFile(target, path, "work"); if (laneNow.current !== lane || !r.available) return; setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha }); setDraft(r.lines.join("\n")); } } catch (err) { if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); } finally { if (laneNow.current === lane) setBusy(false); } }; // Undo and the commit checkbox 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 hunkActable = (f: LaneChangeFile) => range === "work" && f.unstaged && !f.untracked && !f.binary; const hunkActions = (f: LaneChangeFile) => hunkActable(f) ? (change: LaneDiffChange) => { const key = `${f.path}\n${change.hunk.header}\n${change.block}`; const armed = armedHunk === key; return ( <> toggleBlock(f, change)} /> ); } : undefined; // What Commit sends: whole files as `paths`, and for a partly ticked file // its ticked blocks as `hunks` — read fresh at the default context, so the // headers are the ones the server's own re-read will find. const selection = async () => { const paths = tickedPaths.filter((p) => !excluded.get(p)?.size); const hunks: Record = {}; for (const p of tickedPaths.filter((p) => excluded.get(p)?.size)) { const r = await api.lanes.changes(target, { range: "work", path: p, context: CONTEXT_STEPS[0], }); const f = r.available ? r.files.find((x) => x.path === p) : undefined; const picks = new Map(); for (const [c, key] of blockKeys(f?.hunks ?? [])) { if (excluded.get(p)?.has(key)) continue; picks.set(c.hunk.header, [...(picks.get(c.hunk.header) ?? []), c.block]); } if (picks.size) hunks[p] = [...picks].map(([hunk, blocks]) => ({ hunk, blocks })); } return { paths, hunks: Object.keys(hunks).length ? hunks : undefined }; }; // Commit (and Push): the ticked files, `git commit --only` on the server. const commitTicked = async (push = false) => { // Amend rewrites the last commit: asked, like every history rewrite. if ( amend && !(await confirmDialog({ title: t("changes.amendTitle"), message: t("changes.amendConfirm", { subject: git?.available ? git.subject : "", }), confirmLabel: t("changes.amend"), })) ) { return; } const lane = laneId; let committed = false; setBusy(true); setError(null); try { const { paths, hunks } = await selection(); const part = hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}; if (amend) { // No message keeps the old one (`--no-edit`); no file ticked rewords. // `sha`: the commit the dialog named (ESTALE if HEAD moved since). const head = git?.available ? git.head : null; await api.lanes.gitAction(target, "amend", { ...(message.trim() ? { message: message.trim() } : {}), ...(head ? { sha: head } : {}), paths, ...part, }); } else { await api.lanes.gitAction(target, "commit", { message: message.trim(), paths, ...part }); } committed = true; if (laneNow.current !== lane) return; // Cleared before the push: a failed push must not invite a second commit. setMessage(""); setAmend(false); if (push) await api.lanes.gitAction(target, "push", {}); refreshGit(); await load(); } catch (err) { if (laneNow.current !== lane) return; const why = err instanceof Error ? err.message : String(err); setError(committed ? t("changes.pushFailed", { error: why }) : why); if (committed) { refreshGit(); void load(); } } 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, tickedPaths); 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); return load(); }; const [rereading, runReread] = useSpin(rereadAll); 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)); // What Commit sends: the ticked files still listed, in list order. Never a // conflicted one (they have no checkbox). const tickedPaths = range === "work" ? files.filter((f) => ticked.has(f.path) && !f.conflicted).map((f) => f.path) : []; const canCommit = !busy && !suggesting && (amend ? tickedPaths.length > 0 || !!message.trim() : tickedPaths.length > 0 && !!message.trim()); // A newly listed file takes its default — a tracked change ticked, an // unversioned or never-commit one not — and a file gone from the list // leaves the set. Only the full overview list may say "gone". useEffect(() => { if (!data?.available || data.range !== "work" || mode !== "overview") return; const seen = seenPaths.current; setTicked((prev) => { const next = new Set(); for (const f of data.files) { const on = seen?.has(f.path) ? prev.has(f.path) : !f.untracked && !f.conflicted; if (on) next.add(f.path); } return next; }); seenPaths.current = new Set(data.files.map((f) => f.path)); setExcluded((prev) => new Map([...prev].filter(([p]) => seenPaths.current!.has(p)))); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); useEffect(() => { if (data?.available && data.range === range && (mode === "overview" || mode === "review")) { setListed(data.files.map((f) => f.path)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [data]); const dropExcluded = (paths: string[]) => setExcluded((prev) => { if (!paths.some((p) => prev.has(p))) return prev; const next = new Map(prev); for (const p of paths) next.delete(p); return next; }); // A file's commit box: all of it, none of it, or some of its blocks. const fileTick = (path: string): boolean | "some" => !ticked.has(path) ? false : excluded.get(path)?.size ? "some" : true; const blockIncluded = (f: LaneChangeFile, change: LaneDiffChange) => { const key = blockKeys(f.hunks ?? []).get(change); return ticked.has(f.path) && !(key && excluded.get(f.path)?.has(key)); }; // One block's checkbox. In an unticked file it takes just that block, as // JetBrains does; leaving every block out unticks the file. const toggleBlock = (f: LaneChangeFile, change: LaneDiffChange) => { const keys = blockKeys(f.hunks ?? []); const key = keys.get(change); if (!key) return; const all = [...keys.values()]; const out = ticked.has(f.path) ? new Set(excluded.get(f.path)) : new Set(all); if (!out.delete(key)) out.add(key); const none = all.every((k) => out.has(k)); tick(f.path, !none); setExcluded((prev) => { const next = new Map(prev); if (none || out.size === 0) next.delete(f.path); else next.set(f.path, out); return next; }); }; const tick = (path: string, on: boolean) => { dropExcluded([path]); setTicked((prev) => { const next = new Set(prev); if (on) next.add(path); else next.delete(path); return next; }); }; // A folder's or a group's box: every file in it, whole, on or off. const tickAll = (rows: LaneChangeFile[], on: boolean) => { dropExcluded(rows.map((f) => f.path)); setTicked((prev) => { const next = new Set(prev); for (const f of rows) { if (on) next.add(f.path); else next.delete(f.path); } return next; }); }; // Commit File…: that file alone, and the message box to type into. const commitFile = (path: string) => { setTicked(new Set([path])); setExcluded(new Map()); messageRef.current?.focus(); }; // Copy as Patch: the file's diff read for its path, as `git apply` takes it. const copyPatch = async (p: string) => { try { const r = await api.lanes.changes(target, { range, ...(range === "commit" && sha ? { sha } : {}), path: p, context: 3, }); const f = r.available ? r.files.find((x) => x.path === p) : undefined; if (!f?.hunks?.length) { toast.error(t("changes.noPatch")); return; } await copyText(toPatch(f)); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } }; const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined; // "vs base" needs a base, and means nothing on the base branch itself. An // older server reports no `base` at all: keep the toggle as it was. const base = git?.available ? git.base : undefined; const showBranchRange = base === undefined || (base !== null && !(git?.available && git.branch === base)); // The armed path left the list (committed, reverted, lane switched): disarm, // so a later reappearance starts from the first click again. useEffect(() => { if (!showBranchRange && range === "branch") setRange("work"); }, [showBranchRange, range]); 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); }; // Jump to Source: the whole file, changed or not. const jumpToSource = async (p: string) => { if (!(await confirmDrop())) return; showNow(p); 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} />
    )}
    ); return (
    { if (e.key !== "F7" || (mode !== "file" && mode !== "review")) return; e.preventDefault(); stepDiff(e.shiftKey ? -1 : 1); }} > {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" && (
    {/* With nothing to compare against, "Uncommitted" alone would be a toggle with one side: none is drawn, unless a commit is open — then Uncommitted is the way back. */} {(showBranchRange || (range === "commit" && sha)) && (
    {(showBranchRange ? (["work", "branch"] as const) : (["work"] 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" || mode === "review") && (
    stepDiff(-1)}> stepDiff(1)}> {mode === "file" && ( <> stepFile(-1)} > = listed.length - 1} onClick={() => stepFile(1)} > )}
    )} {mode === "file" && (
    {([false, true] as const).map((w) => ( ))}
    )}
    )}
    {error && (

    {error}

    )} {git?.available && git.merging && (
    {git.conflicts ? t("changes.mergeBanner", { count: git.conflicts, op: git.operation ?? "merge" }) : t("changes.mergeReady", { op: git.operation ?? "merge" })}
    )} {/* 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" ? (
      {historyFile && (
    • {t("changes.historyOfFile", { path: historyFile })}
    • )} {historyBranches.length > 1 && (
    • {t("changes.historyOf")}
    • )} {(() => { 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(source ? "changes.unavailableFolder" : "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. */} {range === "work" && !opOpen && files.some((f) => !f.conflicted) && (
    {/* One row when there is room; in a narrow panel the buttons wrap below rather than squeeze the message to a few characters (its min width is what triggers the wrap). */}
    setMessage(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e) && canCommit) void commitTicked(); }} placeholder={t( amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder" )} aria-label={t( amend ? "changes.amendPlaceholder" : "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" />
    )} {/* The work range splits like JetBrains' Commit window: tracked changes (staged or not — the checkbox picks, not the index), then the files git does not know yet. */}
    {(range === "work" ? [ { key: "conflicts", rows: files.filter((f) => f.conflicted) }, { key: "changes", rows: files.filter((f) => !f.untracked && !f.conflicted) }, { key: "unversioned", rows: files.filter((f) => f.untracked && !f.conflicted), }, ] : [{ key: "all", rows: files }] ).map( (g) => g.rows.length > 0 && (
    {g.key !== "all" && (
    {g.key !== "conflicts" && ( tickAll(g.rows, on)} /> )}
    )} {!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} ticked={fileTick(f.path)} onTick={ g.key === "changes" || g.key === "unversioned" ? tick : undefined } /> )} folderTick={ g.key === "changes" || g.key === "unversioned" ? (rows, path) => ( tickAll(rows, on)} /> ) : 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 && ( )} {range === "work" && !openImage && editable && ( )} {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")}

    } > { const at = a === b ? `L${a}` : `L${a}-${b}`; if (!comment(` @${openFile}#${at} `)) toast.error(t("changes.commentNoPane")); }} onSave={() => { if (!busy) void saveDraft(); }} // The same Undo / Keep as the diff, on the file on // disk — so only while the editor holds no unsaved text. onBlock={ current && hunkActable(current) && !dirty && !busy ? (action: BlockAction, b: EditorBlock) => { const change = gitChangeFor(current, b); if (!change) return setError(t("changes.blockNotInDiff")); if (action === "include") toggleBlock(current, change); else void actHunk("discard-hunk", current.path, change); } : undefined } blockIncluded={(b: EditorBlock) => { const change = current && gitChangeFor(current, b); return change ? blockIncluded(current, change) : false; }} blame={annotate && blame?.path === openFile ? blame : undefined} onBlameClick={viewCommit} navRef={editorNav} />
    ) ) : 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()} > {!f.conflicted && ( tick(f.path, on)} /> )} void act(...a)} onArm={setConfirmDiscard} vcs={!f.conflicted} /> )}
    {!shut && ( )}
    ); })}
    )} {data?.available && data.truncated && mode !== "history" && mode !== "browse" && (

    {t("changes.truncated")}

    )}
    ); } export default LaneChangesPanel;