Files
AgentHub/client/src/components/lanes/LaneChangesPanel.tsx
T
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00

2308 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 `<path>:<line> — <text>`, 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ĩ <vinnt@smartgift.vn>
*/
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 <span className="shrink-0 font-mono text-[10px] text-fg-muted">{t("changes.bin")}</span>;
return (
<span className="shrink-0 font-mono text-[10px]">
{added ? <span className="text-status-success">+{added}</span> : null}
{added && deleted ? " " : null}
{deleted ? <span className="text-status-danger">−{deleted}</span> : null}
</span>
);
}
/** 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 (
<div
data-testid="lane-file-image"
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
>
{failed ? (
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
) : (
<img
src={src}
alt={path}
onError={() => setFailed(true)}
className="max-h-full max-w-full object-contain"
/>
)}
</div>
);
}
/** 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<string, LaneTreeEntry[]>;
/** Paths on the never-stage list: drawn orange, whatever their git status. */
kept?: Set<string>;
expanded: Set<string>;
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 (
<li key={e.path}>
<button
type="button"
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
data-tree-kept={isKept || undefined}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
aria-current={active === e.path ? "true" : undefined}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
isKept
? "bg-kept/10 text-kept"
: mark
? mark.tone
: e.changed
? "text-status-warning"
: "text-fg-primary"
} ${active === e.path ? "!bg-surface-4" : ""}`}
>
{e.dir ? (
expanded.has(e.path) ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)
) : (
<span className="w-3 shrink-0" />
)}
{e.dir ? (
<Folder className="h-3 w-3 shrink-0 text-accent" />
) : (
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span
className={`min-w-0 flex-1 truncate ${e.status?.includes("D") ? "line-through" : ""}`}
>
{e.name}
</span>
{mark ? (
<span className="shrink-0 pr-1 font-semibold" title={e.status}>
{mark.letter}
</span>
) : e.changed ? (
// A folder only says "something in here" — the dot, not a letter.
<span className="mr-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-status-warning" />
) : null}
</button>
{e.dir && expanded.has(e.path) && (
<ul>
<TreeNodes
path={e.path}
depth={depth + 1}
children={children}
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
onMenu={onMenu}
active={active}
kept={kept}
/>
</ul>
)}
</li>
);
})}
</>
);
}
function IconButton({
label,
onClick,
disabled,
danger,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={`shrink-0 rounded p-0.5 disabled:opacity-40 ${
danger ? "text-fg-muted hover:text-status-danger" : "text-fg-muted hover:text-fg-primary"
}`}
>
{children}
</button>
);
}
/** 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 (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => onAct("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => onAct("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
{onKeep && (kept || f.unstaged || f.untracked) ? (
<IconButton
label={t(kept ? "changes.allowStage" : "changes.neverStage")}
onClick={() => onKeep(f.path)}
>
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
</IconButton>
) : null}
<IconButton
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy}
danger
onClick={() => {
// 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);
}}
>
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
</span>
);
}
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 (
<span className="flex shrink-0 items-center gap-0.5">
{(["ours", "theirs"] as const).map((side) => (
<button
key={side}
type="button"
disabled={busy}
onClick={() => onResolve(f.path, side)}
title={t(side === "ours" ? "changes.takeOursHint" : "changes.takeTheirsHint")}
className={`shrink-0 rounded border px-1 text-[10px] disabled:opacity-40 ${
side === "ours"
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: "border-accent/40 text-accent hover:bg-accent/10"
}`}
>
{t(side === "ours" ? "changes.takeOurs" : "changes.takeTheirs")}
</button>
))}
<IconButton
label={t("changes.markResolved")}
disabled={busy}
onClick={() => onResolve(f.path, "done")}
>
<Check className="h-3 w-3" />
</IconButton>
</span>
);
}
/** 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 <body>). */
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 (
<li
className="group flex items-baseline gap-1"
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
onContextMenu={(e) => onMenu(e, f)}
>
<button
type="button"
onClick={() => onOpen(f.path)}
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span
className={`w-3 shrink-0 font-mono text-[11px] font-semibold ${statusClass(
f.status,
f.untracked
)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<span
className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
kept ? "text-fg-muted line-through" : "text-fg-primary"
}`}
title={kept ? `${f.path}\n${t("changes.keptHint")}` : f.path}
>
{name ?? f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" &&
(f.conflicted ? (
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
) : (
<FileActions
f={f}
busy={busy}
armed={armed}
onAct={onAct}
onArm={onArm}
kept={kept}
onKeep={onKeep}
/>
))}
</li>
);
}
/** 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<LaneChangeFile>;
depth: number;
/** The overview group, so the same folder folds apart in each. */
group: string;
collapsed: Set<string>;
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 (
<li key={d.path}>
<div
className="group flex items-center gap-1"
style={{ paddingLeft: `${depth * 0.9}rem` }}
>
<button
type="button"
aria-expanded={open}
onClick={() => onToggle(key)}
title={d.path}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{open ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<Folder className="h-3 w-3 shrink-0 text-accent" />
<span className="min-w-0 truncate">{d.name}</span>
<span className="shrink-0 font-sans text-[10px] text-fg-muted">
{t("changes.fileCount", { count: d.count })}
</span>
</button>
{folderAction?.(treeFiles(d))}
</div>
{open && (
<ul className="space-y-0.5">
<ChangeTreeNodes
dir={d}
depth={depth + 1}
group={group}
collapsed={collapsed}
onToggle={onToggle}
renderFile={renderFile}
folderAction={folderAction}
/>
</ul>
)}
</li>
);
})}
{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 (
<svg
aria-hidden
width={width * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
</svg>
);
}
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<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(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<string | null>(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<DiffLayout>(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<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(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<Record<string, LaneTreeEntry[]>>({});
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [hits, setHits] = useState<string[] | null>(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<string | null>(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<string | null>(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<Set<string>>(new Set());
// The overview tree's folded groups and folders (`<group>:<folder path>`);
// everything starts open.
const [treeCollapsed, setTreeCollapsed] = useState<Set<string>>(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<Set<string>>(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<HTMLDivElement>(null);
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
const [suggesting, setSuggesting] = useState(false);
const [error, setError] = useState<string | null>(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 =
<T,>(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<typeof api.lanes.gitAction>[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<LaneChangeFile>();
const treeCtx = useContextMenu<LaneTreeEntry>();
const commitCtx = useContextMenu<LaneCommit>();
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 && (
<ContextMenu
x={fileCtx.menu.x}
y={fileCtx.menu.y}
onClose={fileCtx.close}
testId="lane-changes-file-menu"
>
<MenuItem onSelect={pick(fileCtx.close, () => show(menuFile.path))}>
{t("changes.openFile", { path: menuFile.path.split("/").pop() })}
</MenuItem>
{range === "work" && menuFile.conflicted && (
<>
<MenuSeparator />
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "ours"))}
>
{t("changes.takeOurs")}
</MenuItem>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "theirs"))}
>
{t("changes.takeTheirs")}
</MenuItem>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "done"))}
>
{t("changes.markResolved")}
</MenuItem>
</>
)}
{range === "work" && !menuFile.conflicted && (
<>
<MenuSeparator />
{(menuFile.unstaged || menuFile.untracked) && (
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => void act("stage", menuFile.path))}
>
{t("changes.stage")}
</MenuItem>
)}
{menuFile.staged && (
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => void act("unstage", menuFile.path))}
>
{t("changes.unstage")}
</MenuItem>
)}
<MenuItem onSelect={pick(fileCtx.close, () => toggleNeverStage(menuFile.path))}>
{t(neverStage.has(menuFile.path) ? "changes.allowStage" : "changes.neverStage")}
</MenuItem>
<MenuItem
danger
disabled={busy}
testId="lane-changes-menu-discard"
// Two clicks here too: the first arms (the row's trash
// shows it), only the second reaches the server.
onSelect={() =>
menuArmed
? pick(fileCtx.close, () => void act("discard", menuFile.path, true))()
: setConfirmDiscard(menuFile.path)
}
>
{menuArmed ? t("changes.discardConfirm") : t("changes.discard")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem onSelect={pick(fileCtx.close, () => copy(menuFile.path))}>
{t("changes.copyPath")}
</MenuItem>
</ContextMenu>
)}
{treeCtx.menu && (
<ContextMenu
x={treeCtx.menu.x}
y={treeCtx.menu.y}
onClose={treeCtx.close}
testId="lane-changes-tree-menu"
>
{!treeCtx.menu.item.dir && (
<MenuItem onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}>
{t("changes.openFile", { path: treeCtx.menu.item.name })}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
</MenuItem>
</ContextMenu>
)}
{commitCtx.menu && (
<ContextMenu
x={commitCtx.menu.x}
y={commitCtx.menu.y}
onClose={commitCtx.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
{t("changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
{t("changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
{t("changes.copySubject")}
</MenuItem>
</ContextMenu>
)}
</>
);
// 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 (
<>
<button
type="button"
aria-label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
onClick={() =>
armed ? void actHunk("discard-hunk", f.path, change) : setArmedHunk(key)
}
className={`${HUNK_CHIP} ${
armed
? "border-status-danger bg-status-danger/20 text-status-danger"
: "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger"
}`}
>
{armed ? t("changes.undoHunkShortConfirm") : t("changes.undoHunkShort")}
</button>
<button
type="button"
aria-label={t("changes.stageHunk")}
title={t("changes.stageHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, change)}
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
>
{t("changes.stageHunkShort")}
</button>
</>
);
}
: 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 = (
<div data-testid="lane-changes-browse">
{/* Pinned: the finder is what you reach for after scrolling a
long tree, not only from its top. */}
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-1 pb-2">
<input
value={query}
onChange={(e) => 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 && (
<IconButton
label={
expandedDirs.size > 0
? t("changes.collapseAllFolders")
: t("changes.expandAllFolders")
}
disabled={treeBusy}
onClick={() =>
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
}
>
{expandedDirs.size > 0 ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</IconButton>
)}
</div>
{hits ? (
<ul className="space-y-0.5">
{hits.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => void openWhole(p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
</button>
</li>
))}
{hits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
)}
</ul>
) : (
<ul className="space-y-0.5">
<TreeNodes
path=""
depth={0}
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null}
kept={neverStage}
/>
</ul>
)}
</div>
);
return (
<div
data-testid="lane-changes"
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
>
{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 && (
<aside
data-testid="lane-changes-sidebar"
className="flex w-64 shrink-0 flex-col overflow-y-auto border-r border-border-light pr-2"
>
{browsePane}
</aside>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<LaneGitHeader
laneId={laneId}
session={session}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
{/* 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. */}
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<div className="contents">
{mode === "file" ? (
<button
type="button"
onClick={goBack}
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{/* 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) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => setMode(m)}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`changes.mode.${m}`)}
</button>
))}
</div>
)}
<button
type="button"
onClick={rereadAll}
aria-label={t("changes.refresh")}
title={t("changes.refresh")}
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{mode !== "history" && mode !== "browse" && (
<div className="contents">
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["work", "branch"] as const).map((r) => (
<button
key={r}
type="button"
aria-pressed={range === r}
onClick={() => {
setRange(r);
setSha(null);
}}
title={t(`changes.range.${r}Hint`)}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
range === r
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`changes.range.${r}`)}
</button>
))}
{range === "commit" && sha && (
<span
data-testid="lane-changes-commit-chip"
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
>
{sha.slice(0, 7)}
</span>
)}
</div>
{(mode === "file" || mode === "review") && (
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
{mode === "review" && (
<IconButton
label={allCollapsed ? t("changes.expandAll") : t("changes.collapseAll")}
disabled={files.length === 0}
onClick={() =>
setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path)))
}
>
{allCollapsed ? (
<ChevronsUpDown className="h-3.5 w-3.5" />
) : (
<ChevronsDownUp className="h-3.5 w-3.5" />
)}
</IconButton>
)}
<IconButton
label={t(`changes.layout.${layout === "unified" ? "split" : "unified"}`)}
onClick={() => setLayout(layout === "unified" ? "split" : "unified")}
>
{layout === "unified" ? (
<Columns2 className="h-3.5 w-3.5" />
) : (
<Rows3 className="h-3.5 w-3.5" />
)}
</IconButton>
{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. */}
<IconButton
label={t("changes.collapseContext")}
disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
onClick={() =>
setContext(
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
CONTEXT_STEPS[0]
)
}
>
<FoldVertical className="h-3.5 w-3.5" />
</IconButton>
<IconButton
label={t("changes.expandContext")}
disabled={whole || draft !== null || context >= LAST_CONTEXT}
onClick={() =>
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
}
>
<UnfoldVertical className="h-3.5 w-3.5" />
</IconButton>
</>
)}
</div>
)}
{mode === "file" && (
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{([false, true] as const).map((w) => (
<button
key={String(w)}
type="button"
aria-pressed={whole === w}
onClick={() => {
if (whole === w) return;
// Both views of a working file are the same editor: the
// draft goes along, unsaved or not.
if (editable && draft !== null) return setWhole(w);
// Back to Changes drops an open edit, so unsaved text asks first.
void confirmDrop().then((ok) => {
if (!ok) return;
if (w) editWanted.current = editableFile(range, openFile ?? "", current);
else setDraft(null);
setWhole(w);
});
}}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
whole === w
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(w ? "changes.wholeFile" : "changes.changesView")}
</button>
))}
</div>
)}
</div>
)}
</div>
{error && (
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
{error}
</p>
)}
{git?.available && git.merging && (
<div
data-testid="lane-merge-banner"
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
>
<span className="min-w-0 flex-1">
{git.conflicts
? t("changes.mergeBanner", { count: git.conflicts })
: t("changes.mergeReady")}
</span>
<button
type="button"
disabled={busy || !!git.conflicts}
onClick={() => void mergeAct("merge-commit")}
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.mergeCommit")}
</button>
<button
type="button"
disabled={busy}
// Two clicks, like discard: aborting drops every resolution made.
onClick={() =>
armedAbort ? void mergeAct("merge-abort", { confirm: true }) : setArmedAbort(true)
}
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
>
{armedAbort ? t("changes.mergeAbortConfirm") : t("changes.mergeAbort")}
</button>
</div>
)}
{/* 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. */}
<div
ref={scroller}
data-testid="lane-changes-body"
className={`flex min-h-0 flex-1 flex-col ${
mode === "file" ? "overflow-hidden" : "overflow-y-auto"
}`}
>
{mode === "history" ? (
<ul data-testid="lane-changes-history">
{(() => {
const list = commits ?? [];
const graph = commitGraph(list);
return list.map((c, i) => (
<li key={c.sha}>
<button
type="button"
onClick={() => viewCommit(c.sha)}
onContextMenu={(e) => commitCtx.open(e, c)}
style={{ height: GRAPH_ROW }}
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<GraphCell row={graph.rows[i]!} width={graph.width} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</span>
</button>
</li>
));
})()}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
<li>
<button
type="button"
data-testid="lane-changes-history-more"
disabled={historyLoading}
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
</button>
</li>
)}
</ul>
) : mode === "browse" && !full ? (
browsePane
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
) : files.length === 0 && mode !== "file" ? (
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
) : 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) && (
<div
data-testid="lane-changes-actions"
className="sticky top-0 z-10 mb-1.5 flex items-center gap-1.5 border-b border-border-light bg-surface-1 pb-2"
>
{/* ONE row: message, commit, then the two bulk index moves. */}
{stagedCount > 0 && (
<div
data-testid="lane-changes-commit"
className="flex min-w-0 flex-1 items-center gap-1.5"
>
<input
value={message}
onChange={(e) => 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"
/>
<button
type="button"
data-testid="lane-changes-suggest-message"
disabled={suggesting}
onClick={() => void suggestMessage()}
title={t("changes.suggestMessage")}
aria-label={t("changes.suggestMessage")}
className="shrink-0 flex items-center rounded border border-border-light px-1 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{suggesting ? (
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
) : (
<Sparkles className="h-3 w-3" aria-hidden />
)}
</button>
<button
type="button"
disabled={busy || suggesting || !message.trim()}
onClick={() => void commitStaged()}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.commitStaged", { count: stagedCount })}
</button>
</div>
)}
{toStage.length > 0 && (
<button
type="button"
disabled={busy}
onClick={() =>
void act(
"stage",
toStage.map((f) => f.path)
)
}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus className="h-3 w-3" />
{t("changes.stageAll", { count: toStage.length })}
</button>
)}
{stagedCount > 0 && (
<button
type="button"
data-testid="lane-changes-unstage-all"
disabled={busy}
onClick={() =>
void act(
"unstage",
files.filter((f) => f.staged).map((f) => f.path)
)
}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Minus className="h-3 w-3" />
{t("changes.unstageAll", { count: stagedCount })}
</button>
)}
</div>
)}
{/* 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. */}
<div data-testid="lane-changes-overview">
{(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 && (
<div
key={g.key}
className="mb-1.5"
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
>
{g.key !== "all" && (
<button
type="button"
aria-expanded={!treeCollapsed.has(`${g.key}:`)}
onClick={() => toggleTreeDir(`${g.key}:`)}
className="flex w-full items-center gap-1.5 px-1 py-0.5 text-left text-[10px] font-semibold uppercase tracking-wide text-fg-secondary hover:text-fg-primary"
>
{treeCollapsed.has(`${g.key}:`) ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
{t(`changes.${g.key}Group`)}
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
{g.rows.length}
</span>
</button>
)}
{!treeCollapsed.has(`${g.key}:`) && (
<ul data-group={g.key} className="space-y-0.5">
{/* Folders like JetBrains' commit view: a long
change-set reads by area, not as one flat list. */}
<ChangeTreeNodes
dir={changeTree(g.rows)}
depth={g.key === "all" ? 0 : 1}
group={g.key}
collapsed={treeCollapsed}
onToggle={toggleTreeDir}
renderFile={(f, name, depth) => (
<ChangeRow
key={f.path}
f={f}
name={name}
depth={depth}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => 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) => (
<IconButton
label={t(
g.key === "staged"
? "changes.unstageFolder"
: "changes.stageFolder"
)}
disabled={busy}
onClick={() =>
void act(
g.key === "staged" ? "unstage" : "stage",
rows
.filter(
(f) => g.key === "staged" || !neverStage.has(f.path)
)
.map((f) => f.path)
)
}
>
{g.key === "staged" ? (
<Minus className="h-3 w-3" />
) : (
<Plus className="h-3 w-3" />
)}
</IconButton>
)
: undefined
}
/>
</ul>
)}
</div>
)
)}
</div>
</>
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
<LaneConflictView
laneId={laneId}
session={session}
path={openFile}
onResolved={() => {
refreshGit();
void load();
goBack();
}}
onEdit={() => {
setRawEdit(true);
editWanted.current = true;
setWhole(true);
}}
/>
) : mode === "file" ? (
current || (whole && text) || openImage ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1 flex items-baseline gap-1.5">
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
{openFile}
</span>
{current && (
<Delta
added={current.added}
deleted={current.deleted}
binary={current.binary}
/>
)}
{whole && text && !dirty && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{/* 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 ? (
<span
data-testid="lane-file-noedit"
className="shrink-0 self-center text-[10px] text-status-warning"
>
{t(noEditReason(text))}
</span>
) : draft === null && whole ? null : draft === null ? (
<button
type="button"
data-testid="lane-file-edit"
disabled={busy}
onClick={() => {
// The file is not loaded yet: the editor opens as
// soon as the whole-file read lands.
editWanted.current = true;
setWhole(true);
}}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Pencil className="h-3 w-3" />
{t("changes.edit")}
</button>
) : (
<>
<button
type="button"
data-testid="lane-file-save"
disabled={busy || !dirty}
onClick={() => void saveDraft()}
className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40"
>
{t("changes.save")}
</button>
<button
type="button"
data-testid="lane-file-cancel"
disabled={busy || !dirty}
onClick={() =>
void confirmDrop().then(
(ok) => ok && setDraft(text ? text.lines.join("\n") : null)
)
}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("changes.cancelEdit")}
</button>
</>
))}
</div>
{openImage ? (
<ImageView
path={openImage}
src={api.lanes.gitRawUrl(
target,
openImage,
range === "commit" && sha ? sha : "work"
)}
/>
) : !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") ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : (
<Suspense
fallback={<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>}
>
<DiffEditor
key={`${openFile}:${layout}:${whole}`}
layout={layout}
collapse={!whole}
revertLabel={t("changes.revertBlock")}
value={draft}
original={baseText ?? draft}
lang={langFor(openFile ?? "")}
label={t("changes.editorLabel", { path: openFile })}
onChange={setDraft}
onSave={() => {
if (!busy) void saveDraft();
}}
/>
</Suspense>
)
) : whole ? (
text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
) : text ? (
<LaneFileView
path={text.path}
lines={text.lines}
truncated={text.truncated}
fill
/>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
)
) : current ? (
<LaneDiffView
file={current}
layout={layout}
fill
hunkActions={whole ? undefined : hunkActions(current)}
onComment={comment}
/>
) : null}
</div>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.fileGone")}</p>
)
) : (
<div data-testid="lane-changes-review" className="space-y-3">
{files.map((f) => {
const shut = collapsed.has(f.path);
return (
<div
key={f.path}
data-review-file=""
className="rounded border border-border-light"
>
{/* 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. */}
<div
role="button"
tabIndex={0}
onClick={() => 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"
}`}
>
<ChevronRight
className={`h-3 w-3 shrink-0 self-center text-fg-muted transition-transform ${
shut ? "" : "rotate-90"
}`}
/>
<span
className={`shrink-0 font-mono text-[11px] font-semibold ${statusClass(f.status, f.untracked)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
show(f.path);
}}
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-fg-primary hover:underline"
title={t("changes.openFile", { path: f.path })}
>
{f.path}
</button>
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
{range === "work" && (
// The header folds on click; its buttons must not.
<span className="self-center" onClick={(e) => e.stopPropagation()}>
<FileActions
f={f}
busy={busy}
armed={confirmDiscard === f.path}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
</span>
)}
</div>
{!shut && (
<LaneDiffView
file={f}
layout={layout}
hunkActions={hunkActions(f)}
onComment={comment}
/>
)}
</div>
);
})}
</div>
)}
{data?.available && data.truncated && mode !== "history" && mode !== "browse" && (
<p className="mt-2 text-[10px] text-status-warning">{t("changes.truncated")}</p>
)}
</div>
</div>
</div>
);
}
export default LaneChangesPanel;