Files
AgentHub/client/src/components/lanes/LaneChangesPanel.tsx
T
nntrivi2001 faa56f5c75 feat(lanes): move the lane's git section into the Changes panel
- The branch line, the uncommitted-files hover panel and the git action
  row (fetch/pull/push/branch) move from the lane card to the top of the
  Changes panel (new LaneGitHeader.tsx); the card has no git row now.
- Header and panel share one git-facts read: stage/unstage/discard/commit
  and Refresh re-read it, and a git bar action re-reads the diff, the
  history and the file tree.
- The git bar no longer commits: the panel's staged-only box is the one
  commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi).
- "New lane here" is wired from Workspace to the panel.
2026-09-24 11:44:31 +07:00

1052 lines
40 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 with its +/- counts and its stage/discard
* actions; click one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
* `commit` (one commit, reached from history).
*
* Only the overview polls. Review, file, history and browse are a deliberate
* read of a frozen moment — re-fetching under the reader's scroll position is
* worse than a slightly stale view, and those are the expensive shapes.
*
* Rendering lives in LaneDiffView.tsx. Every write goes through
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
* before the server is told `confirm: true` — it is the one action in this
* panel git cannot undo.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
Columns2,
FileText,
FoldVertical,
Folder,
Minus,
Plus,
RefreshCw,
Rows3,
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneTreeEntry,
} from "../../lib/types";
/** How often the overview re-reads. Summary only — no patch text. */
const OVERVIEW_REFRESH_MS = 15_000;
/** Context steps the expand button walks through, then the whole file. */
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
/** Tree reads "expand all" keeps in flight at once. */
const TREE_CONCURRENCY = 4;
type Mode = "overview" | "review" | "file" | "history" | "browse";
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
function statusClass(status: string, untracked: boolean) {
if (untracked) return "text-status-success";
const code = status.trim()[0];
if (code === "A") return "text-status-success";
if (code === "D") return "text-status-danger";
if (code === "R") return "text-accent";
return "text-status-warning";
}
/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file
* has no line delta at all, so it says `bin` rather than leaving the row's
* right edge blank, which reads as "changed by nothing". */
function Delta({
added,
deleted,
binary,
}: {
added: number | null;
deleted: number | null;
binary?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
if (binary)
return <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>
);
}
/**
* One folder level of the browse tree, rendered in place and recursing into
* every expanded child — so opening a nested file no longer means navigating
* away from its siblings.
*/
function TreeNodes({
path,
depth,
children,
expanded,
onToggleDir,
onOpenFile,
}: {
path: string;
depth: number;
children: Record<string, LaneTreeEntry[]>;
expanded: Set<string>;
onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void;
}) {
const entries = children[path];
if (!entries) return null;
return (
<>
{entries.map((e) => (
<li key={e.path}>
<button
type="button"
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
className="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{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.name}</span>
</button>
{e.dir && expanded.has(e.path) && (
<ul>
<TreeNodes
path={e.path}
depth={depth + 1}
children={children}
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
/>
</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>
);
}
/** 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,
}: {
f: LaneChangeFile;
range: LaneChangeRange;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
onOpen: (path: string) => void;
onAct: (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<li className="group flex items-baseline gap-1">
<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] text-fg-primary"
title={f.path}
>
{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" && (
<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}
<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>
)}
</li>
);
}
export function LaneChangesPanel({
laneId,
onNewLane,
}: {
laneId: number;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
const [whole, setWhole] = useState(false);
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 } | null>(
null
);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
// Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Where opening a file came FROM: its mode and how far the list was scrolled.
// Reviewing a change-set is a walk down a long list, and landing back at the
// top of it after every file is the whole cost of opening one.
const scroller = useRef<HTMLDivElement>(null);
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
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(laneId, {
range,
...(range === "commit" && sha ? { sha } : {}),
...(mode === "file" && openFile ? { path: openFile, context } : {}),
...(mode === "review" ? { full: true } : {}),
});
if (seq !== loadSeq.current) return;
setData(result);
setError(null);
} catch (err) {
if (seq !== loadSeq.current) return;
// A tracked file opened from Files that has nothing to diff: a normal
// state, not a fault. The whole-file view is the answer; no banner.
if (mode === "file" && (err as { code?: string })?.code === "ENOFILECHANGE") {
setData({
available: true,
range,
base: null,
baseRef: null,
sha,
context,
files: [],
truncated: false,
});
setError(null);
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [laneId, range, sha, mode, openFile, context]);
useEffect(() => {
if (mode === "history" || mode === "browse") return;
void load();
// Only the cheap shape polls; see the file header.
if (mode !== "overview") return;
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
return () => window.clearInterval(timer);
}, [load, mode]);
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
useEffect(() => {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId, reread]);
useEffect(() => {
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
void api.lanes
.gitTree(laneId, "")
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` is the cache this fills — reading it as a dep would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, laneId, query, reread]);
// A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const [treeBusy, setTreeBusy] = useState(false);
// Which lane `treeChildren` was filled for; the reset effect moves it.
const treeLane = useRef(laneId);
// Fetches whatever level isn't cached yet, depth-first, so "expand all"
// works even on a tree nothing has been opened in.
const expandAllDirs = async () => {
// A walk is many reads long; a lane switch mid-walk must not land lane A's
// tree in lane B, nor start from a cache the switch has not cleared yet.
const lane = laneId;
setTreeBusy(true);
const cache = treeLane.current === lane ? { ...treeChildren } : {};
const toExpand = new Set(expandedDirs);
// Level by level, at most TREE_CONCURRENCY reads in flight: one request
// per folder all at once is thousands on a monorepo.
const visit = async (path: string, next: string[]) => {
let entries = cache[path];
if (!entries) {
entries = (await api.lanes.gitTree(lane, path)).entries;
cache[path] = entries;
}
for (const e of entries) {
if (!e.dir) continue;
toExpand.add(e.path);
next.push(e.path);
}
};
try {
for (let level = [""]; level.length && laneNow.current === lane; ) {
const next: string[] = [];
// Workers share one iterator, so each path is taken exactly once.
const it = level.values();
await Promise.all(
Array.from({ length: TREE_CONCURRENCY }, async () => {
for (const path of it) await visit(path, next);
})
);
level = next;
}
if (laneNow.current !== lane) return;
setTreeChildren(cache);
setExpandedDirs(toExpand);
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setTreeBusy(false);
}
};
useEffect(() => {
const q = query.trim();
if (mode !== "browse" || !q) {
setHits(null);
return;
}
// `live` drops an answer that a newer query, a mode change or a lane
// switch has overtaken — a slow "main" must not overwrite "mainx".
let live = true;
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(laneId, q)
.then((r) => live && setHits(r.paths))
.catch(() => live && setHits([]));
}, 150);
return () => {
live = false;
window.clearTimeout(timer);
};
}, [mode, laneId, query]);
// The whole-file read is a different endpoint from the diff, so it is its
// own fetch — and only while the viewer is actually showing it.
useEffect(() => {
if (mode !== "file" || !whole || !openFile) return;
void api.lanes
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.then(
forLane(laneId, (r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]);
// A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one.
useEffect(() => {
setMode("overview");
setOpenFile(null);
setSha(null);
setRange("work");
setTreeChildren({});
treeLane.current = laneId;
setExpandedDirs(new Set());
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(null);
setCollapsed(new Set());
// An armed discard names a PATH, not a file in a lane — carried over, one
// click would discard the same path in the lane just switched to.
setConfirmDiscard(null);
// Same for a half-typed commit message: it describes the OTHER lane's work.
setMessage("");
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
// Restoring has to wait for the list to be TALL enough again: leaving a file
// re-fetches the mode it came from, so the first render back is an empty
// box that would swallow the scroll.
useLayoutEffect(() => {
const el = scroller.current;
const want = pendingScroll.current;
if (mode === "file" || want == null || !el) return;
if (el.scrollHeight - el.clientHeight < want) return;
el.scrollTop = want;
pendingScroll.current = null;
}, [mode, data]);
const show = (path: string) => {
// Opening a file FROM a file (the review list is gone by then) must not
// overwrite the list position that is still waiting to be restored.
if (mode !== "file") {
returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 };
}
setOpenFile(path);
setContext(CONTEXT_STEPS[0]);
setWhole(false);
setText(null);
setMode("file");
};
// An action that resolves after a lane switch belongs to the lane left: its
// `load` is the pre-switch one and would take a fresh ticket and paint the
// old lane's files here, so everything after the await is dropped.
const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, { paths: [path], ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
const commitStaged = async () => {
const lane = laneId;
setBusy(true);
setError(null);
try {
// `only: "staged"` — the whole point of staging file by file is that the
// commit takes the index as it stands, with no `add -A` sweeping the
// rest of the tree in behind it.
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
if (laneNow.current !== lane) return;
setMessage("");
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
const rereadAll = () => {
refreshGit();
// The tree cache is dropped whole: after a switch any folder may differ.
setTreeChildren({});
setExpandedDirs(new Set());
setReread((n) => n + 1);
void load();
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
// The armed path left the list (committed, reverted, lane switched): disarm,
// so a later reappearance starts from the first click again.
useEffect(() => {
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
setConfirmDiscard(null);
}
}, [data, files, confirmDiscard]);
return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
{/* Two rows on purpose: WHERE you are (mode, and the refresh that always
applies) on top, HOW you are looking at it (range, layout, context,
whole file) underneath. One row wrapped mid-group as soon as the
panel was narrow, which is every panel beside a console. */}
<div className="mb-2 space-y-1.5">
<div className="flex items-center gap-1.5">
{mode === "file" ? (
<button
type="button"
onClick={() => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
}}
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">
{(["browse", "overview", "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 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="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="flex flex-wrap items-center gap-1.5">
<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 || 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 || context >= LAST_CONTEXT}
onClick={() => setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)}
>
<UnfoldVertical className="h-3.5 w-3.5" />
</IconButton>
</>
)}
</div>
)}
{mode === "file" && (
<button
type="button"
aria-pressed={whole}
onClick={() => setWhole(!whole)}
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
}`}
>
{t("changes.wholeFile")}
</button>
)}
</div>
)}
</div>
{error && (
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
{error}
</p>
)}
{/* 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" className="space-y-0.5">
{(commits ?? []).map((c) => (
<li key={c.sha}>
<button
type="button"
onClick={() => {
setSha(c.sha);
setRange("commit");
setMode("review");
}}
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<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>
</button>
</li>
))}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
</ul>
) : mode === "browse" ? (
<div data-testid="lane-changes-browse">
<div className="mb-2 flex items-center gap-1.5">
<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={() => {
show(p);
setWhole(true);
}}
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) => {
show(p);
setWhole(true);
}}
/>
</ul>
)}
</div>
) : !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" ? (
<>
<ul data-testid="lane-changes-overview" className="space-y-0.5">
{files.map((f) => (
<ChangeRow
key={f.path}
f={f}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
))}
</ul>
{stagedCount > 0 && (
<div
data-testid="lane-changes-commit"
className="mt-2 flex items-center gap-1.5 border-t border-border-light pt-2"
>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
}}
placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || !message.trim()}
onClick={() => void commitStaged()}
className="shrink-0 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>
)}
</>
) : mode === "file" ? (
current || (whole && text) ? (
<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} />
)}
</div>
{whole ? (
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 />
) : 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} 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)}
onKeyDown={(e) => {
// Only the header's own keys: Enter on the path button
// inside bubbles here and must open, not fold.
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCollapsed(f.path);
}
}}
aria-expanded={!shut}
aria-label={
shut
? t("changes.expandFile", { path: f.path })
: t("changes.collapseFile", { path: f.path })
}
className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
shut ? "" : "border-b border-border-light"
}`}
>
<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} />
</div>
{!shut && <LaneDiffView file={f} layout={layout} />}
</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>
);
}
export default LaneChangesPanel;