1597c110d4
The session section and the Files/Changes panel drop their cards; one vertical divider separates them (none at full width), and the collapsed rail keeps only that divider. The terminal goes back to the page colour (--surface-0), and the panel's two sticky bars cover scrolled content in the page colour instead of the card's. Inside, the git row loses its bordered box for a bottom divider, and the ended-session prompt box drops its own fill and shadow.
1685 lines
67 KiB
TypeScript
1685 lines
67 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); a
|
||
* whole markdown file can also be read rendered (Preview)
|
||
* history — recent commits; opening one shows that commit's diff
|
||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||
* Workspace right panel's Files tab; the other four are its
|
||
* Changes tab)
|
||
*
|
||
* 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.
|
||
* @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,
|
||
Check,
|
||
Code2,
|
||
Columns2,
|
||
Eye,
|
||
FileText,
|
||
FoldVertical,
|
||
Folder,
|
||
Minus,
|
||
Pencil,
|
||
Plus,
|
||
RefreshCw,
|
||
Rows3,
|
||
Trash2,
|
||
UnfoldVertical,
|
||
} from "lucide-react";
|
||
import { api, gitTarget } from "../../lib/api";
|
||
import { pasteToLane } from "../../lib/panePaste";
|
||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||
import { LaneConflictView } from "./LaneConflictView";
|
||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||
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]!;
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
/** 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,
|
||
active,
|
||
}: {
|
||
path: string;
|
||
depth: number;
|
||
children: Record<string, LaneTreeEntry[]>;
|
||
expanded: Set<string>;
|
||
onToggleDir: (path: string) => void;
|
||
onOpenFile: (path: string) => void;
|
||
/** The file open beside the tree, drawn selected. */
|
||
active?: string | null;
|
||
}) {
|
||
const entries = children[path];
|
||
if (!entries) return null;
|
||
return (
|
||
<>
|
||
{entries.map((e) => {
|
||
const mark = e.status ? treeMark(e.status) : null;
|
||
return (
|
||
<li key={e.path}>
|
||
<button
|
||
type="button"
|
||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||
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 ${
|
||
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}
|
||
active={active}
|
||
/>
|
||
</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,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
busy: boolean;
|
||
armed: boolean;
|
||
onAct: FileAct;
|
||
onArm: (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}
|
||
<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,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
range: LaneChangeRange;
|
||
onResolve: Resolve;
|
||
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">
|
||
<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" &&
|
||
(f.conflicted ? (
|
||
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
|
||
) : (
|
||
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
|
||
))}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export function LaneChangesPanel({
|
||
laneId,
|
||
session,
|
||
onNewLane,
|
||
full = false,
|
||
tab = "changes",
|
||
}: {
|
||
laneId: number;
|
||
/** 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;
|
||
/** The Workspace right panel's activity-bar tab: `files` is the browse mode
|
||
* on its own (no git header, no mode switcher), `changes` the other four.
|
||
* At full width both show the explorer sidebar beside the changes. */
|
||
tab?: "files" | "changes";
|
||
/** 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;
|
||
}) {
|
||
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) => pasteToLane(laneId, text), [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);
|
||
// 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);
|
||
// Where the editor opens: the line clicked in the read view, and that view's
|
||
// scroll offset, so the text does not jump when the textarea replaces it.
|
||
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
|
||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||
const editing = draft !== null;
|
||
useEffect(() => {
|
||
const at = editAt.current;
|
||
const el = editorRef.current;
|
||
if (!editing || !at || !el) return;
|
||
editAt.current = null;
|
||
const offset = el.value
|
||
.split("\n")
|
||
.slice(0, at.line)
|
||
.reduce((n, l) => n + l.length + 1, 0);
|
||
el.setSelectionRange(offset, offset);
|
||
el.focus({ preventScroll: true });
|
||
el.scrollTop = at.scrollTop;
|
||
}, [editing]);
|
||
const startEdit = (line: number, scrollTop: number) => {
|
||
if (!text?.sha) return;
|
||
editAt.current = { line, scrollTop };
|
||
setDraft(text.lines.join("\n"));
|
||
};
|
||
useEffect(() => {
|
||
if (!editWanted.current || !text) return;
|
||
editWanted.current = false;
|
||
if (text.sha) startEdit(0, 0);
|
||
// 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);
|
||
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
|
||
const [armedAbort, setArmedAbort] = useState(false);
|
||
// 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}`.
|
||
const [armedHunk, setArmedHunk] = 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(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.
|
||
useEffect(() => {
|
||
if (mode !== "history") return;
|
||
void api.lanes
|
||
.gitLog(target, { limit: 50 })
|
||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||
}, [mode, laneId, reread]);
|
||
|
||
// Re-run when a lane switch empties the cache too: the Files tab stays in
|
||
// `browse` 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 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(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, openFile, laneId, range, sha]);
|
||
|
||
useEffect(() => {
|
||
if (full && mode === "browse") setMode("overview");
|
||
}, [full, mode]);
|
||
// Going full width with a working file already open: straight to the editor.
|
||
useEffect(() => {
|
||
if (full && mode === "file" && range !== "commit") setWhole(true);
|
||
// Only on the switch itself — Whole file must still toggle afterwards.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [full]);
|
||
|
||
// 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: each tab opens on its own mode.
|
||
useEffect(() => {
|
||
if (!full) setMode(tab === "files" ? "browse" : "overview");
|
||
}, [tab, laneId, full]);
|
||
const filesTab = tab === "files" && !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]);
|
||
|
||
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]);
|
||
// Full width edits by default: a working file opens as the editor, and
|
||
// Whole file toggles back to its diff. A commit is history, so a diff.
|
||
setWhole(full && range !== "commit");
|
||
setText(null);
|
||
setDraft(null);
|
||
setRawEdit(false);
|
||
setMode("file");
|
||
};
|
||
|
||
const goBack = () => {
|
||
const back = returnTo.current;
|
||
pendingScroll.current = back?.scroll ?? 0;
|
||
returnTo.current = null;
|
||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||
setOpenFile(null);
|
||
setWhole(false);
|
||
};
|
||
|
||
// 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 });
|
||
}
|
||
setDraft(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 });
|
||
|
||
// 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);
|
||
}
|
||
};
|
||
|
||
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");
|
||
|
||
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) : [];
|
||
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 the whole file — and, with an edit open,
|
||
// would drop it, so a dirty draft asks first.
|
||
const openWhole = (p: string) => {
|
||
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
|
||
show(p);
|
||
setWhole(true);
|
||
};
|
||
|
||
// The Files mode's body. At full width it lives in the sidebar instead, so
|
||
// the Files tab 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-0 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={() => 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={openWhole}
|
||
active={full && mode === "file" ? openFile : null}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
data-testid="lane-changes"
|
||
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
|
||
>
|
||
{/* 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">
|
||
{!filesTab && (
|
||
<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>
|
||
) : filesTab ? null : (
|
||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||
{(["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="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 || 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}
|
||
disabled={dirty}
|
||
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>
|
||
)}
|
||
|
||
{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" 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" && !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-0 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 (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 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" && (
|
||
<div className="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-fg-secondary">
|
||
{t(`changes.${g.key}Group`)}
|
||
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
|
||
{g.rows.length}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<ul data-group={g.key} className="space-y-0.5">
|
||
{g.rows.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}
|
||
onResolve={resolve}
|
||
/>
|
||
))}
|
||
</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) ? (
|
||
<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 && draft === null && 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. */}
|
||
{/* The whole file enters the editor by a click on its text;
|
||
only the diff, which has no whole text to click, keeps
|
||
an Edit button. */}
|
||
{range !== "commit" &&
|
||
(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 ? (
|
||
text &&
|
||
!mdPreview && (
|
||
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
||
{t("changes.clickToEdit")}
|
||
</span>
|
||
)
|
||
) : 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"
|
||
disabled={busy}
|
||
onClick={() => setDraft(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>
|
||
{whole && draft !== null ? (
|
||
<textarea
|
||
ref={editorRef}
|
||
data-testid="lane-file-editor"
|
||
aria-label={t("changes.editorLabel", { path: openFile })}
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||
e.preventDefault();
|
||
if (!busy) void saveDraft();
|
||
}
|
||
}}
|
||
spellCheck={false}
|
||
wrap="off"
|
||
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
|
||
/>
|
||
) : 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
|
||
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
|
||
/>
|
||
) : (
|
||
<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)}
|
||
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-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} />
|
||
{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;
|