faa56f5c75
- 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.
1052 lines
40 KiB
TypeScript
1052 lines
40 KiB
TypeScript
/**
|
||
* @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;
|