Files
AgentHub/client/src/components/lanes/LaneChangesPanel.tsx
T
nntrivi2001 e94038481e feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files
  tab, portalled so no panel clips it), laid out like GNOME Files: places
  (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with
  image thumbnails or list, labelled New/Upload, selection action bar, hidden
  files behind Ctrl+H, right-click menus and the usual keys.
  Make, rename, move, copy (within a workspace, between workspaces, home ↔
  workspace), delete into a per-account trash restorable for 7 days, streamed
  upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both /
  Skip on a taken name), download a file or a zip of anything.
  server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*.
- The user's home: every dot entry at its top stays hidden and refused
  (also via a symlink, download, zip or as a copy source); an admin may view
  other users' homes read-only, audited.
- write-as learns move/copy (re-run `sudo agenthub server install-system`).
- Hardening: a write to a workspace re-checks its owner by the live role;
  S3 keeps its server.conf if the file stops reading and a tmux call never
  falls back to the host's server; the AI Proxy gateway marks what it relays
  and the dashboard refuses it; a NUL in a pane's argv/env is refused.
- OpenAPI for the new routes (server/openapi-extra/files.js); docs (API,
  SERVER-MODE, CLAUDE.md) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:17:39 +07:00

3146 lines
126 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file The Changes panel: the lane's code beside its console, in the five
* shapes a review actually needs —
*
* overview — every changed file, in a folder tree (JetBrains' commit view):
* Changes and Unversioned Files, each file, folder and group
* with a checkbox, its +/- counts and its discard action; click
* one to open it
* review — every file's full diff, stacked, for reading a whole change-set
* file — one file, as a diff or as its whole content (issue #35); a
* working file is always the editor (`DiffEditor`, diffed
* against HEAD / the branch's merge base, unified or side by
* side, each block with the diff's Undo / Keep while nothing
* is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the
* mode switcher's Files button; at full width the sidebar);
* a row dragged onto a prompt box attaches it as `@path`
*
* and three ranges: `work` (uncommitted, what the agent has touched right
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
* `commit` (one commit, reached from history).
*
* A file can also be opened from outside the panel (`openRequest`: a chat's
* "Edited N files" card), the same way a click in the tree opens it.
*
* Only the overview polls. Review, file, history and browse are a deliberate
* read of a frozen moment — re-fetching under the reader's scroll position is
* worse than a slightly stale view, and those are the expensive shapes.
*
* Rendering lives in LaneDiffView.tsx. Every write goes through
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
* before the server is told `confirm: true` — it is the one action in this
* panel git cannot undo.
*
* During a merge, cherry-pick or revert (issue #45) a banner offers
* **Complete** (only once no file is conflicted) and **Abort** (two clicks,
* `confirm: true`), and conflicted files get their own group with Keep mine /
* Take theirs / Mark resolved instead of stage — staging one blind would mark
* it resolved.
*
* The commit is JetBrains' (PhpStorm's Commit window without its staging
* area): Commit takes exactly the TICKED files' working-tree content
* (`paths`, `git commit --only`), whatever the index holds. A tracked change
* arrives ticked, an Unversioned one unticked, a never-commit one (this
* browser's list) never by default; Commit and Push pushes after it. A file's
* menu adds Commit File…, Jump to Source, Copy as Patch, Add to VCS and Add to
* .gitignore (`ignore`, untracked only). While a merge is open the box is
* hidden: a partial commit cannot finish one, Complete does.
*
* History rewrites through its commit menu: Revert (any commit), Undo commit
* (the top one; the server refuses a pushed one) and, while another branch's
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
* folds the ticked files into the last commit (none ticked: rewords it). All
* four ask first (`confirmDialog`).
*
* "vs base" compares against the lane's base, else the repo's default branch
* (`git.base`), and is hidden when there is none or the lane IS on it.
*
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
* a toast says which tab took it, and a console not on screen takes nothing.
* A selection in the editor offers Add to Chat, which pastes ` @<path>#La-b`
* (Claude Code's line-range mention — without the `L` it attaches the whole
* file) the same way.
*
* An unsaved whole-file edit is never dropped silently: Back, another file,
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
* reloading the browser tab asks the browser's own question, and
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
* switching its tab, or moving it to another workspace or session. Two-click
* actions (discard, a hunk's Undo, Abort) disarm after 3 s (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronsDownUp,
ChevronsUpDown,
Check,
Download,
Code2,
Columns2,
Eye,
FileText,
FoldVertical,
Folder,
Minus,
Pencil,
Plus,
RefreshCw,
Rows3,
Sparkles,
Trash2,
UnfoldVertical,
FolderOpen,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useSpin } from "../../hooks/useSpin";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import type { BlockAction, EditorBlock } from "./DiffEditor";
import {
LaneDiffView,
LaneFileView,
changeBlocks,
langFor,
type DiffLayout,
type LaneDiffChange,
} from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach";
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
import type {
LaneChangeFile,
LaneChangeRange,
LaneChanges,
LaneBlame,
LaneCommit,
LaneDiffHunk,
LaneTreeEntry,
} from "../../lib/types";
/** How often the overview re-reads. Summary only — no patch text. */
const OVERVIEW_REFRESH_MS = 15_000;
/** Context steps the expand button walks through, then the whole file. */
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
type Mode = "overview" | "review" | "file" | "history" | "browse";
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
/** JetBrains' VCS colours: unversioned orange, added green, deleted grey,
* modified (and renamed) blue. */
function statusClass(status: string, untracked: boolean) {
if (untracked) return "text-kept";
const code = status.trim()[0];
if (code === "A") return "text-status-success";
if (code === "D") return "text-fg-muted";
return "text-diff-mod";
}
/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file
* has no line delta at all, so it says `bin` rather than leaving the row's
* right edge blank, which reads as "changed by nothing". */
function Delta({
added,
deleted,
binary,
}: {
added: number | null;
deleted: number | null;
binary?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
if (binary)
return <span className="shrink-0 font-mono text-[10px] text-fg-muted">{t("changes.bin")}</span>;
return (
<span className="shrink-0 font-mono text-[10px]">
{added ? <span className="text-status-success">+{added}</span> : null}
{added && deleted ? " " : null}
{deleted ? <span className="text-status-danger">−{deleted}</span> : null}
</span>
);
}
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited.
const DiffEditor = lazy(() => import("./DiffEditor"));
/** A file that opens straight in the editor: a working file with text — not a
* commit's, an image, a binary, a deleted file or a merge conflict (which
* opens in the conflict view). */
function editableFile(range: LaneChangeRange, path: string, f?: LaneChangeFile) {
if (range === "commit" || IMAGE_FILE.test(path)) return false;
return !f || (!f.binary && !f.conflicted && !f.status.includes("D"));
}
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
function ImageView({ src, path }: { src: string; path: string }) {
const { t } = useTranslation("lanes");
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
return (
<div
data-testid="lane-file-image"
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
>
{failed ? (
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
) : (
<img
src={src}
alt={path}
onError={() => setFailed(true)}
className="max-h-full max-w-full object-contain"
/>
)}
</div>
);
}
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
* key naming it, so the reader is never left with a button that does nothing. */
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
if (text.binary) return "changes.noEditBinary";
if (text.truncated) return "changes.noEditTooBig";
return "changes.noEdit";
}
/** The explorer's decoration for a porcelain code: one letter + JetBrains'
* colour (U unversioned orange, A added green, D deleted grey, R/M blue,
* ! conflicted red). */
function treeMark(code: string): { letter: string; tone: string } {
// The tint fills the whole row, not just the letter, so a changed file
// stands out of a long tree at a glance.
if (code === "??") return { letter: "U", tone: "text-kept bg-kept/10" };
// A merge conflict (git's unmerged codes) before A/D: `AA`/`DU` are not adds or deletes.
if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code))
return { letter: "!", tone: "text-status-danger bg-status-danger/10" };
if (code.includes("A")) return { letter: "A", tone: "text-status-success bg-status-success/10" };
if (code.includes("D")) return { letter: "D", tone: "text-fg-muted" };
if (code.includes("R")) return { letter: "R", tone: "text-diff-mod bg-diff-mod/10" };
return { letter: "M", tone: "text-diff-mod bg-diff-mod/10" };
}
/**
* One folder level of the browse tree, rendered in place and recursing into
* every expanded child — so opening a nested file no longer means navigating
* away from its siblings.
*/
function TreeNodes({
path,
depth,
children,
expanded,
onToggleDir,
onOpenFile,
onMenu,
active,
}: {
path: string;
depth: number;
children: Record<string, LaneTreeEntry[]>;
expanded: Set<string>;
onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void;
/** Right-click on an entry. */
onMenu?: (e: React.MouseEvent, entry: LaneTreeEntry) => void;
/** The file open beside the tree, drawn selected. */
active?: string | null;
}) {
const entries = children[path];
if (!entries) return null;
return (
<>
{entries.map((e) => {
const mark = e.status ? treeMark(e.status) : null;
return (
<li key={e.path}>
<button
type="button"
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
data-tree-ignored={e.ignored || undefined}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
draggable
onDragStart={(ev) => dragProjectPath(ev, e.dir ? `${e.path}/` : 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 ${
e.ignored
? "text-fg-muted opacity-70"
: mark
? mark.tone
: e.changed
? "text-diff-mod"
: "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-diff-mod" />
) : null}
</button>
{e.dir && expanded.has(e.path) && (
<ul>
<TreeNodes
path={e.path}
depth={depth + 1}
children={children}
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
onMenu={onMenu}
active={active}
/>
</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";
/** The git change block an editor block is, by its line span on both sides;
* none when the editor's diff and git's split the change differently. */
export function gitChangeFor(f: LaneChangeFile, b: EditorBlock): LaneDiffChange | undefined {
return (f.hunks ?? []).flatMap(changeBlocks).find((c) => {
const lines = c.hunk.lines.slice(c.firstLine, c.lastLine + 1);
const del = lines.filter((l) => l.type === "del");
const add = lines.filter((l) => l.type === "add");
return (
del.length === b.oldCount &&
add.length === b.newCount &&
(!del.length || del[0]!.oldLine === b.oldFrom) &&
(!add.length || add[0]!.newLine === b.newFrom)
);
});
}
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
/** A work-range file's stage / unstage / discard buttons — the overview row's
* and the review header's. */
function FileActions({
f,
busy,
armed,
onAct,
onArm,
vcs = false,
}: {
f: LaneChangeFile;
busy: boolean;
armed: boolean;
onAct: FileAct;
onArm: (path: string) => void;
/** The overview's JetBrains rows: no index moves, only Add to VCS for an
* untracked file — the checkbox is what picks a file for the commit. */
vcs?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
return (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged && !vcs ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => onAct("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{(vcs ? f.untracked : f.unstaged || f.untracked) ? (
<IconButton
label={t(vcs ? "changes.addToVcs" : "changes.stage")}
disabled={busy}
onClick={() => onAct("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
{/* Two clicks, always: the first arms the row, the second is the only
one that reaches the server (which itself refuses the call without
`confirm: true`). Armed, the icon becomes a red word, so the second
click is asked for where the eye already is. */}
{armed ? (
<button
type="button"
data-testid="lane-changes-discard-confirm"
aria-label={t("changes.discardConfirm")}
title={t("changes.discardConfirm")}
disabled={busy}
onClick={() => onAct("discard", f.path, true)}
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
>
{t("changes.discardShortConfirm")}
</button>
) : (
<IconButton
label={t("changes.discard")}
disabled={busy}
danger
onClick={() => onArm(f.path)}
>
<Trash2 className="h-3 w-3" />
</IconButton>
)}
</span>
);
}
/** A commit checkbox: ticked, unticked or — for a folder or a group with
* some files ticked — indeterminate, which a click clears. */
function TickBox({
state,
label,
onChange,
}: {
state: boolean | "some";
label: string;
onChange: (on: boolean) => void;
}) {
return (
<input
type="checkbox"
ref={(el) => {
if (el) el.indeterminate = state === "some";
}}
checked={state === true}
onChange={() => onChange(state === false)}
onClick={(e) => e.stopPropagation()}
aria-label={label}
title={label}
className="h-3 w-3 shrink-0 cursor-pointer self-center"
/>
);
}
/** A folder's or group's TickBox state from its files' own. */
function tickState(
files: LaneChangeFile[],
fileTick: (path: string) => boolean | "some"
): boolean | "some" {
const states = files.map((f) => fileTick(f.path));
if (states.every((x) => x === true)) return files.length ? true : false;
return states.every((x) => x === false) ? false : "some";
}
const keysOf = new WeakMap<LaneDiffHunk[], Map<LaneDiffChange, string>>();
/** Each change block of a file, keyed by its content (its -/+ lines) and how
* many identical blocks came before it — so an unticked block stays
* unticked across a re-read at another context or an edit elsewhere in the
* file, which both move its hunk header. */
export function blockKeys(hunks: LaneDiffHunk[]): Map<LaneDiffChange, string> {
const hit = keysOf.get(hunks);
if (hit) return hit;
const seen = new Map<string, number>();
const out = new Map<LaneDiffChange, string>();
for (const c of hunks.flatMap(changeBlocks)) {
const sig = c.hunk.lines
.slice(c.firstLine, c.lastLine + 1)
.map((l) => `${l.type === "add" ? "+" : "-"}${l.text}`)
.join("\n");
const n = seen.get(sig) ?? 0;
seen.set(sig, n + 1);
out.set(c, `${n}\n${sig}`);
}
keysOf.set(hunks, out);
return out;
}
/** One file's diff as a unified patch (Copy as Patch). ponytail: git's
* "\ No newline at end of file" markers are not in the parsed hunks, so a
* file missing its last newline applies with one; read `git diff` raw if
* that ever matters. */
function toPatch(f: LaneChangeFile): string {
const added = f.untracked || f.status.includes("A");
const deleted = f.status.includes("D");
return [
`diff --git a/${f.path} b/${f.path}`,
`--- ${added ? "/dev/null" : `a/${f.path}`}`,
`+++ ${deleted ? "/dev/null" : `b/${f.path}`}`,
...(f.hunks ?? []).flatMap((h) => [
h.header,
...h.lines.map((l) => `${l.type === "add" ? "+" : l.type === "del" ? "-" : " "}${l.text}`),
]),
"",
].join("\n");
}
type Resolve = (path: string, side: "ours" | "theirs" | "done") => void;
/** A conflicted file's three ways out of the conflict. */
function ConflictActions({
f,
busy,
onResolve,
}: {
f: LaneChangeFile;
busy: boolean;
onResolve: Resolve;
}) {
const { t } = useTranslation(["lanes"]);
return (
<span className="flex shrink-0 items-center gap-0.5">
{(["ours", "theirs"] as const).map((side) => (
<button
key={side}
type="button"
disabled={busy}
onClick={() => onResolve(f.path, side)}
title={t(side === "ours" ? "changes.takeOursHint" : "changes.takeTheirsHint")}
className={`shrink-0 rounded border px-1 text-[10px] disabled:opacity-40 ${
side === "ours"
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: "border-accent/40 text-accent hover:bg-accent/10"
}`}
>
{t(side === "ours" ? "changes.takeOurs" : "changes.takeTheirs")}
</button>
))}
<IconButton
label={t("changes.markResolved")}
disabled={busy}
onClick={() => onResolve(f.path, "done")}
>
<Check className="h-3 w-3" />
</IconButton>
</span>
);
}
/** One overview row. At module scope on purpose: declared inside the panel it
* was a new component type every render, so each poll and each keystroke in
* the commit box remounted every row (focus dropped to <body>). */
function ChangeRow({
f,
range,
busy,
armed,
onOpen,
onAct,
onArm,
onResolve,
onMenu,
name,
depth = 0,
ticked,
onTick,
}: {
f: LaneChangeFile;
/** In the commit: the row's checkbox (absent = no checkbox). */
ticked?: boolean | "some";
onTick?: (path: string, on: boolean) => void;
/** In the folder tree: the file's own name, and how deep it sits. */
name?: string;
depth?: number;
range: LaneChangeRange;
onResolve: Resolve;
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
onOpen: (path: string) => void;
onAct: FileAct;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<li
className="group flex items-baseline gap-1"
// A file has no chevron: reserve its 1rem so the letter lines up
// with a sibling folder's icon, one step inside the parent folder.
style={{ paddingLeft: `${depth + 1}rem` }}
onContextMenu={(e) => onMenu(e, f)}
>
{onTick && (
<TickBox
state={ticked ?? false}
label={t("changes.tickFile", { path: f.path })}
onChange={(on) => onTick(f.path, on)}
/>
)}
<button
type="button"
onClick={() => onOpen(f.path)}
draggable
onDragStart={(ev) => dragProjectPath(ev, 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] ${statusClass(
f.status,
f.untracked
)}`}
title={f.path}
>
{name ?? f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" &&
(f.conflicted ? (
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
) : (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} vcs={!!onTick} />
))}
</li>
);
}
/** One folder of the overview's change tree: its sub-folders (each a
* collapsible row with its file count), then its files. */
function ChangeTreeNodes({
dir,
depth,
group,
collapsed,
onToggle,
renderFile,
folderTick,
}: {
dir: ChangeTreeDir<LaneChangeFile>;
depth: number;
/** The overview group, so the same folder folds apart in each. */
group: string;
collapsed: Set<string>;
onToggle: (key: string) => void;
renderFile: (f: LaneChangeFile, name: string, depth: number) => React.ReactNode;
/** The folder row's checkbox, before its name. */
folderTick?: (files: LaneChangeFile[], path: string) => React.ReactNode;
}) {
const { t } = useTranslation(["lanes"]);
return (
<>
{dir.dirs.map((d) => {
const key = `${group}:${d.path}`;
const open = !collapsed.has(key);
return (
<li key={d.path}>
<div className="group flex items-center gap-1" style={{ paddingLeft: `${depth}rem` }}>
{folderTick?.(treeFiles(d), d.path)}
<button
type="button"
aria-expanded={open}
onClick={() => onToggle(key)}
title={d.path}
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{open ? (
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
) : (
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<Folder className="h-3 w-3 shrink-0 text-accent" />
<span className="min-w-0 truncate">{d.name}</span>
<span className="shrink-0 font-sans text-[10px] text-fg-muted">
{t("changes.fileCount", { count: d.count })}
</span>
</button>
</div>
{open && (
<ul className="space-y-0.5">
<ChangeTreeNodes
dir={d}
depth={depth + 1}
group={group}
collapsed={collapsed}
onToggle={onToggle}
renderFile={renderFile}
folderTick={folderTick}
/>
</ul>
)}
</li>
);
})}
{dir.files.map(({ name, file }) => renderFile(file, name, depth))}
</>
);
}
/** History's page size, and the most the server reads (`listCommits`). */
const HISTORY_PAGE = 50;
const HISTORY_MAX = 200;
// The history's commit graph: one fixed-height row per commit so its lanes
// join up row to row. Mid-tone hues that read on dark and light themes alike.
const GRAPH_ROW = 34;
const GRAPH_COL = 12;
const GRAPH_COLORS = [
"#3b82f6",
"#22c55e",
"#f59e0b",
"#a855f7",
"#ef4444",
"#14b8a6",
"#ec4899",
"#84cc16",
];
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
const y = (f: number) => f * GRAPH_ROW;
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
return (
<svg
aria-hidden
width={width * GRAPH_COL}
height={GRAPH_ROW}
className="shrink-0"
data-testid="lane-changes-graph"
>
{row.segs.map((g, i) => {
const ym = (y(g.y1) + y(g.y2)) / 2;
return (
<path
key={i}
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
fill="none"
stroke={hue(g.color)}
strokeWidth={1.5}
/>
);
})}
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
</svg>
);
}
export function LaneChangesPanel({
laneId,
session,
source,
onNewLane,
full = false,
wide = full,
onDirtyChange,
openRequest = null,
}: {
laneId: number;
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
openRequest?: { path: string; nonce: number } | null;
/** The whole-file editor holds unsaved text (true) or not (false). */
onDirtyChange?: (dirty: boolean) => void;
/** Show and act on that session's own worktree (`claude --worktree`)
* instead of the workspace's folder. The Workspace keys the panel on it,
* so a different session is a fresh panel. */
session?: string;
/** Show and act on that extra folder's repo instead (`lane_sources` id);
* wins over `session`, and is keyed on like it. */
source?: number;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
* whole file opens straight into the editor. */
full?: boolean;
/** Half the width or more: diffs and the editor open side by side. */
wide?: boolean;
}) {
const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId, session, source);
// A merge, cherry-pick or revert is open: no new one starts from history.
const opOpen = !!(git?.available && git.merging);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session, source);
// A diff line's comment goes into this workspace's pane, as a paste.
const comment = useCallback(
(text: string) => {
const runId = pasteToLane(laneId, text);
if (runId) {
const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0);
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
}
return runId !== null;
},
[laneId, t]
);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
// An image opens as a picture, never as text or a binary diff.
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
// Side by side wherever there is room for two columns; a resize starts over
// from that default, a click on the toggle overrides it until then.
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file.
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [hits, setHits] = useState<string[] | null>(null);
const [text, setText] = useState<{
path: string;
lines: string[];
truncated: boolean;
binary?: boolean;
sha?: string;
} | null>(null);
// The whole-file editor's text while it is open; null = viewing.
const [draft, setDraft] = useState<string | null>(null);
const editWanted = useRef(false);
// The text the editor diffs against: the file at HEAD (`work`) or at the
// branch's merge base; "" for a file that is new there.
const [baseText, setBaseText] = useState<string | null>(null);
const startEdit = () => {
if (!text?.sha) return;
setDraft(text.lines.join("\n"));
};
useEffect(() => {
if (!editWanted.current || !text) return;
editWanted.current = false;
if (text.sha) startEdit();
// Asked for from the diff, refused once the file arrived: say why here,
// not with an Edit button that silently does nothing.
else setError(t(noEditReason(text)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
// Two-click arming that disarms by itself after 3 s: the armed path, the
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
const discardArm = useArmed();
const confirmDiscard = discardArm.armed;
const setConfirmDiscard = (path: string | null) =>
path ? discardArm.arm(path) : discardArm.disarm();
const abortArm = useArmed();
const armedAbort = abortArm.armed !== null;
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
// A conflicted file opens in the conflict view; true = the plain editor.
const [rawEdit, setRawEdit] = useState(false);
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
const hunkArm = useArmed();
const armedHunk = hunkArm.armed;
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
// Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// The overview tree's folded groups and folders (`<group>:<folder path>`);
// everything starts open.
const [treeCollapsed, setTreeCollapsed] = useState<Set<string>>(new Set());
const toggleTreeDir = (key: string) =>
setTreeCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(key)) next.add(key);
return next;
});
// 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("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// The last commit is already on its upstream: the server refuses to amend
// it (EPUSHED), so the box is not offered — asking first and failing after
// the confirm only costs the user a click.
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
useEffect(() => {
if (headPushed) setAmend(false);
}, [headPushed]);
// The files the commit takes (JetBrains' checkboxes). A path listed for the
// first time arrives with its default; `seenPaths` is the last full list,
// so a poll never re-ticks a file the user unticked.
const [ticked, setTicked] = useState<Set<string>>(new Set());
// A ticked file's change blocks left OUT of the commit (each block's own
// checkbox in the diff), by `blockKeys`; absent = the whole file.
const [excluded, setExcluded] = useState<Map<string, Set<string>>>(new Map());
// The last full list (overview or review), in order: Next/Previous file
// walks it while one file is open and `data` holds that file alone.
const [listed, setListed] = useState<string[]>([]);
// History of one file (Show History), not the branch; null = the branch.
const [historyFile, setHistoryFile] = useState<string | null>(null);
// Annotate: who last changed each line of the open file.
const [annotate, setAnnotate] = useState(false);
const [blame, setBlame] = useState<Extract<LaneBlame, { available: true }> | null>(null);
const editorNav = useRef<((dir: 1 | -1) => void) | null>(null);
const seenPaths = useRef<Set<string> | null>(null);
const messageRef = useRef<HTMLInputElement>(null);
// History of another branch (where Cherry-pick finds its commits); null =
// this branch's own.
const [historyRef, setHistoryRef] = useState<string | null>(null);
const [historyBranches, setHistoryBranches] = useState<string[]>([]);
const [suggesting, setSuggesting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Every diff read gets a ticket; only the newest may land. A lane switch (or
// a mode/range change) re-issues the read, and without this a slow reply for
// the previous lane painted its files — and its discard buttons — over this one.
const loadSeq = useRef(0);
// The same guard for the side reads (history, tree, whole file), which only
// race across a lane switch: a reply for a lane no longer shown is dropped.
const laneNow = useRef(laneId);
laneNow.current = laneId;
const forLane =
<T,>(id: number, fn: (v: T) => void) =>
(v: T) => {
if (laneNow.current === id) fn(v);
};
const load = useCallback(async () => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const result = await api.lanes.changes(target, {
range,
...(range === "commit" && sha ? { sha } : {}),
...(mode === "file" && openFile ? { path: openFile, context } : {}),
...(mode === "review" ? { full: true } : {}),
});
if (seq !== loadSeq.current) return;
setData(result);
setError(null);
} catch (err) {
if (seq !== loadSeq.current) return;
// A tracked file opened from Files that has nothing to diff: a normal
// state, not a fault. The whole-file view is the answer; no banner.
if (mode === "file" && (err as { code?: string })?.code === "ENOFILECHANGE") {
setData({
available: true,
range,
base: null,
baseRef: null,
sha,
context,
files: [],
truncated: false,
});
setError(null);
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [laneId, range, sha, mode, openFile, context]);
useEffect(() => {
if (mode === "history" || mode === "browse") return;
void load();
// Only the cheap shape polls; see the file header.
if (mode !== "overview") return;
const timer = window.setInterval(
() => document.visibilityState === "visible" && void load(),
OVERVIEW_REFRESH_MS
);
return () => window.clearInterval(timer);
}, [load, mode]);
// History and the file tree are their own reads, each fetched when its mode
// is entered rather than alongside the diff nobody is looking at.
// History reads HISTORY_PAGE commits, and Load more asks for the next page
// (up to the server's 200).
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (mode !== "history") return;
setHistoryLoading(true);
void api.lanes
.gitLog(target, {
limit: historyLimit,
...(historyRef ? { ref: historyRef } : {}),
...(historyFile ? { file: historyFile } : {}),
})
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
useEffect(() => {
if (mode !== "history") return;
void api.lanes
.gitBranches(target)
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
.catch(() => setHistoryBranches([]));
}, [mode, laneId, reread]);
// Re-run when a lane switch empties the cache too: the full-width sidebar
// stays across the switch, so the mode alone never changes.
const treeRootLoaded = "" in treeChildren;
useEffect(() => {
const wanted = full || (mode === "browse" && !query.trim());
if (!wanted || treeRootLoaded) return;
void api.lanes
.gitTree(target, "")
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` itself is the cache this fills — a dep on it would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, laneId, query, reread, full, treeRootLoaded]);
// A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => {
setExpandedDirs((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
if (path in treeChildren) return;
void api.lanes
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const [treeBusy, setTreeBusy] = useState(false);
// "Expand all" is ONE read of the whole tree: a request per folder meant a
// `git ls-files` per folder, seconds of waiting on a large repo.
const expandAllDirs = async () => {
const lane = laneId;
setTreeBusy(true);
try {
const { levels } = await api.lanes.gitTreeAll(target);
// A lane switch mid-read must not land lane A's tree in lane B.
if (laneNow.current !== lane) return;
setTreeChildren(levels);
setExpandedDirs(new Set(Object.keys(levels).filter(Boolean)));
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setTreeBusy(false);
}
};
useEffect(() => {
const q = query.trim();
if ((mode !== "browse" && !full) || !q) {
setHits(null);
return;
}
// `live` drops an answer that a newer query, a mode change or a lane
// switch has overtaken — a slow "main" must not overwrite "mainx".
let live = true;
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(target, q)
.then((r) => live && setHits(r.paths))
.catch(() => live && setHits([]));
}, 150);
return () => {
live = false;
window.clearTimeout(timer);
};
}, [mode, laneId, query, full]);
// The open file's entry in the change list, and whether it is edited in
// place: a working file with text opens as the editor in both views.
const openEntry =
data?.available && openFile ? data.files.find((f) => f.path === openFile) : undefined;
const editable = mode === "file" && !!openFile && editableFile(range, openFile, openEntry);
// The whole-file read is a different endpoint from the diff, so it is its
// own fetch — and only while the viewer is actually showing it.
useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile) return;
void api.lanes
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then(
forLane(laneId, (r) =>
setText(
r.available
? {
path: r.path,
lines: r.lines,
truncated: r.truncated,
binary: r.binary,
sha: r.sha,
}
: null
)
)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, editable, openFile, laneId, range, sha]);
// The editor's original side, read beside the working file. A file that is
// not there (new since) diffs against nothing: every line is added.
const mergeBase = data?.available ? (data.mergeBase ?? null) : null;
useEffect(() => {
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
void api.lanes.gitFile(target, openFile, rev).then(
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
forLane(laneId, () => setBaseText(""))
);
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
useEffect(() => {
if (full && mode === "browse") setMode("overview");
}, [full, mode]);
// A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one.
useEffect(() => {
setMode("overview");
setOpenFile(null);
setSha(null);
setRange("work");
setTreeChildren({});
setExpandedDirs(new Set());
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(null);
setDraft(null);
setCollapsed(new Set());
// An armed discard names a PATH, not a file in a lane — carried over, one
// click would discard the same path in the lane just switched to.
setConfirmDiscard(null);
// Same for a half-typed commit message: it describes the OTHER lane's work.
setMessage("");
setAmend(false);
setTicked(new Set());
setExcluded(new Map());
setListed([]);
setHistoryFile(null);
seenPaths.current = null;
setHistoryRef(null);
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
// After the lane reset above: a workspace opens on its changes.
// Leaving the open file this way (the Workspace asked first when it was
// edited) drops it whole: a draft left behind would keep `dirty` true.
useEffect(() => {
if (full) return;
setMode("overview");
setOpenFile(null);
setWhole(false);
setDraft(null);
}, [laneId, full]);
// Restoring has to wait for the list to be TALL enough again: leaving a file
// re-fetches the mode it came from, so the first render back is an empty
// box that would swallow the scroll.
useLayoutEffect(() => {
const el = scroller.current;
const want = pendingScroll.current;
if (mode === "file" || want == null || !el) return;
if (el.scrollHeight - el.clientHeight < want) return;
el.scrollTop = want;
pendingScroll.current = null;
}, [mode, data]);
// Every way out of an open, edited file asks once; nothing else does.
const dirtyRef = useRef(false);
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
const showNow = (path: string, inRange: LaneChangeRange = range) => {
// Opening a file FROM a file (the review list is gone by then) must not
// overwrite the list position that is still waiting to be restored.
if (mode !== "file") {
returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 };
}
setOpenFile(path);
setContext(CONTEXT_STEPS[0]);
// A file opens on its Changes. A working file is the editor folded to its
// changes (each block with a Revert); Whole file unfolds it. Anything else
// — a commit, a binary, a deleted file — is the read-only diff.
const f = data?.available ? data.files.find((x) => x.path === path) : undefined;
editWanted.current = editableFile(inRange, path, f);
setWhole(false);
setText(null);
setBaseText(null);
setDraft(null);
setRawEdit(false);
setMode("file");
};
const show = (path: string) => {
void confirmDrop().then((ok) => ok && showNow(path));
};
// A file named from outside the panel (a chat's "Edited N files" card).
useEffect(() => {
if (openRequest) show(openRequest.path);
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per request
}, [openRequest?.nonce]);
// A file's history (Show History): the commits that touched it.
const showHistory = (path: string) => {
void confirmDrop().then((ok) => {
if (!ok) return;
setHistoryFile(path);
setHistoryRef(null);
setCommits(null);
setMode("history");
});
};
// A commit picked from a file's history: that file, as that commit changed it.
const openAtCommit = (path: string, commitSha: string) => {
setSha(commitSha);
setRange("commit");
showNow(path, "commit");
};
// Next / Previous file, in the list the file was opened from.
const fileAt = listed.indexOf(openFile ?? "");
const stepFile = (dir: 1 | -1) => {
const next = listed[fileAt + dir];
if (fileAt >= 0 && next) show(next);
};
// F7 / Shift+F7: the next / previous difference — the editor's own chunks,
// else the diff rows each change block starts on (`data-change-index`).
// ponytail: a windowed diff (500+ rows) keeps off-screen rows out of the
// DOM, so a jump there stops at the last rendered block.
const stepDiff = (dir: 1 | -1) => {
if (mode === "file" && editorNav.current) return editorNav.current(dir);
const root = scroller.current;
if (!root) return;
const top = root.getBoundingClientRect().top + 48;
const marks = [...root.querySelectorAll<HTMLElement>("[data-change-index]")].map((el) => ({
el,
y: el.getBoundingClientRect().top,
}));
const hit =
dir === 1
? marks.find((m) => m.y > top + 1)
: [...marks].reverse().find((m) => m.y < top - 1);
hit?.el.scrollIntoView?.({ block: "center" });
};
const toggleAnnotate = async () => {
if (annotate || !openFile) return setAnnotate(false);
setAnnotate(true);
const lane = laneId;
try {
const r = await api.lanes.gitBlame(target, openFile);
if (laneNow.current !== lane) return;
if (!r.available) {
setAnnotate(false);
toast.error(t("changes.noBlame"));
return;
}
setBlame(r);
} catch (err) {
if (laneNow.current !== lane) return;
setAnnotate(false);
toast.error(err instanceof Error ? err.message : String(err));
}
};
useEffect(() => {
setAnnotate(false);
setBlame(null);
}, [openFile, laneId]);
const goBackNow = () => {
const back = returnTo.current;
pendingScroll.current = back?.scroll ?? 0;
returnTo.current = null;
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
setOpenFile(null);
setWhole(false);
setDraft(null);
};
const goBack = () => {
void confirmDrop().then((ok) => ok && goBackNow());
};
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
// rewrote meanwhile comes back 409 ESTALE and the draft stays for a retry.
const saveDraft = async () => {
if (draft === null || !text?.sha) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const r = await api.lanes.gitSaveFile(target, text.path, draft, text.sha);
if (laneNow.current !== lane) return;
if (r.available) {
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
}
// Saved, still editing: the editor stays on the text just written.
setDraft(r.available ? r.lines.join("\n") : null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// An action that resolves after a lane switch belongs to the lane left: its
// `load` is the pre-switch one and would take a fresh ticket and paint the
// old lane's files here, so everything after the await is dropped.
const act = async (
action: "stage" | "unstage" | "discard" | "ignore",
path: string | string[],
confirm?: boolean
) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
const paths = Array.isArray(path) ? path : [path];
await api.lanes.gitAction(target, action, { paths, ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
// Add to VCS puts the file in the commit, as JetBrains does.
if (action === "stage") setTicked((prev) => new Set([...prev, ...paths]));
setConfirmDiscard(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
body: Parameters<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 });
// Revert / cherry-pick / undo from the history's commit menu. A conflict is
// an operation that STARTED: the overview is where it gets resolved.
const historyAct = async (action: "cherry-pick" | "revert" | "undo-commit", c: LaneCommit) => {
// Every one of the three rewrites this branch's history, so each asks.
const branch = git?.available ? git.branch : "HEAD";
const ask =
action === "undo-commit"
? {
title: t("changes.undoCommitTitle"),
message: t("changes.undoCommitBody", { short: c.short, subject: c.subject }),
confirmLabel: t("changes.undoCommitConfirm"),
}
: action === "revert"
? {
title: t("changes.revertCommit"),
message: t("changes.revertConfirm", { short: c.short, subject: c.subject, branch }),
confirmLabel: t("changes.revertCommit"),
destructive: false,
}
: {
title: t("changes.cherryPick", { branch }),
message: t("changes.cherryPickConfirm", {
short: c.short,
subject: c.subject,
from: historyRef,
branch,
}),
confirmLabel: t("changes.cherryPick", { branch }),
destructive: false,
};
if (!(await confirmDialog(ask))) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const r = await api.lanes.gitAction(
target,
action,
// The commit the dialog named: the server refuses an undo once HEAD
// is another one (ESTALE) — an agent may have committed meanwhile.
action === "undo-commit" ? { confirm: true, sha: c.sha } : { sha: c.sha }
);
if (laneNow.current !== lane) return;
if (r.conflicts?.length) {
toast.error(t("changes.opConflicts", { op: action, count: r.conflicts.length }));
} else if (action !== "undo-commit") {
toast.success(t("changes.opDone", { op: action }));
}
// Conflicts and an undone commit's staged changes are both work-range
// facts; a clean pick or revert is best seen in this branch's history.
setHistoryRef(null);
if (action === "undo-commit" || r.conflicts?.length) {
setRange("work");
setMode("overview");
}
void rereadAll();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
const fileCtx = useContextMenu<LaneChangeFile>();
const treeCtx = useContextMenu<LaneTreeEntry>();
const commitCtx = useContextMenu<LaneCommit>();
const viewCommit = (sha: string) => {
setSha(sha);
setRange("commit");
setMode("review");
};
const copy = (text: string) => void copyText(text);
// A menu item: close its menu, then act.
const pick = (close: () => void, fn: () => void) => () => {
close();
fn();
};
const menuFile = fileCtx.menu?.item;
const menuArmed = !!menuFile && confirmDiscard === menuFile.path;
const menus = (
<>
{fileCtx.menu && menuFile && (
<ContextMenu
x={fileCtx.menu.x}
y={fileCtx.menu.y}
onClose={fileCtx.close}
testId="lane-changes-file-menu"
>
{range === "work" && !menuFile.conflicted && !opOpen && (
<>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => commitFile(menuFile.path))}
>
{t("changes.commitFile")}
</MenuItem>
<MenuSeparator />
</>
)}
<MenuItem onSelect={pick(fileCtx.close, () => show(menuFile.path))}>
{t("changes.openFile", { path: menuFile.path.split("/").pop() })}
</MenuItem>
{!menuFile.status.includes("D") && (
<MenuItem onSelect={pick(fileCtx.close, () => void jumpToSource(menuFile.path))}>
{t("changes.jumpToSource")}
</MenuItem>
)}
<MenuItem onSelect={pick(fileCtx.close, () => showHistory(menuFile.path))}>
{t("changes.showHistory")}
</MenuItem>
{range === "work" && menuFile.conflicted && (
<>
<MenuSeparator />
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "ours"))}
>
{t("changes.takeOurs")}
</MenuItem>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "theirs"))}
>
{t("changes.takeTheirs")}
</MenuItem>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "done"))}
>
{t("changes.markResolved")}
</MenuItem>
</>
)}
{range === "work" && !menuFile.conflicted && (
<>
<MenuSeparator />
{menuFile.untracked && (
<>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => void act("stage", menuFile.path))}
>
{t("changes.addToVcs")}
</MenuItem>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => void act("ignore", menuFile.path))}
>
{t("changes.addToGitignore")}
</MenuItem>
</>
)}
{/* The index is not what Commit takes any more, but a hunk
staged from a review still sits there: the way back out. */}
{menuFile.staged && (
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => void act("unstage", menuFile.path))}
>
{t("changes.unstage")}
</MenuItem>
)}
<MenuItem
danger
disabled={busy}
testId="lane-changes-menu-discard"
// Two clicks here too: the first arms (the row's trash
// shows it), only the second reaches the server.
onSelect={() =>
menuArmed
? pick(fileCtx.close, () => void act("discard", menuFile.path, true))()
: setConfirmDiscard(menuFile.path)
}
>
{menuArmed ? t("changes.discardConfirm") : t("changes.discard")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem onSelect={pick(fileCtx.close, () => copy(menuFile.path))}>
{t("changes.copyPath")}
</MenuItem>
{!menuFile.binary && (
<MenuItem onSelect={pick(fileCtx.close, () => void copyPatch(menuFile.path))}>
{t("changes.copyPatch")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={pick(fileCtx.close, () => void runReread())}>
{t("changes.refresh")}
</MenuItem>
</ContextMenu>
)}
{treeCtx.menu && (
<ContextMenu
x={treeCtx.menu.x}
y={treeCtx.menu.y}
onClose={treeCtx.close}
testId="lane-changes-tree-menu"
>
{!treeCtx.menu.item.dir && (
<>
<MenuItem
onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}
>
{t("changes.openFile", { path: treeCtx.menu.item.name })}
</MenuItem>
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
{t("changes.showHistory")}
</MenuItem>
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
</>
)}
{treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-upload"
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
>
{t("changes.uploadHere")}
</MenuItem>
)}
{treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-explorer"
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
>
{t("changes.openInExplorer")}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
</MenuItem>
</ContextMenu>
)}
{commitCtx.menu && (
<ContextMenu
x={commitCtx.menu.x}
y={commitCtx.menu.y}
onClose={commitCtx.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
{t("changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
{t("changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
{t("changes.copySubject")}
</MenuItem>
<MenuSeparator />
{historyRef ? (
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-cherry-pick"
onSelect={pick(
commitCtx.close,
() => void historyAct("cherry-pick", commitCtx.menu!.item)
)}
>
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
</MenuItem>
) : (
<>
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-revert"
onSelect={pick(
commitCtx.close,
() => void historyAct("revert", commitCtx.menu!.item)
)}
>
{t("changes.revertCommit")}
</MenuItem>
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
() => void historyAct("undo-commit", commitCtx.menu!.item)
)}
>
{t("changes.undoCommit")}
</MenuItem>
)}
</>
)}
</ContextMenu>
)}
</>
);
// One change block, named by its hunk's `@@` line and its index inside that
// hunk, matched against a fresh diff at the context this diff was read
// with — so the server never applies a stale patch.
const actHunk = async (
action: "stage-hunk" | "discard-hunk",
path: string,
change: LaneDiffChange
) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(target, action, {
path,
hunk: change.hunk.header,
block: change.block,
context: data?.available ? data.context : undefined,
...(action === "discard-hunk" ? { confirm: true } : {}),
});
if (laneNow.current !== lane) return;
setArmedHunk(null);
refreshGit();
await load();
// Undone under the open editor: it shows the file as it is now (the
// bars only exist while nothing is unsaved, so no draft is lost).
if (action === "discard-hunk" && text?.path === path) {
const r = await api.lanes.gitFile(target, path, "work");
if (laneNow.current !== lane || !r.available) return;
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
setDraft(r.lines.join("\n"));
}
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// Undo and the commit checkbox on each change block of a file with unstaged changes. The diff is
// HEAD → working tree, so a staged-only file gets none: undoing its tree
// would leave the index holding the change.
const hunkActable = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary;
const hunkActions = (f: LaneChangeFile) =>
hunkActable(f)
? (change: LaneDiffChange) => {
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
const armed = armedHunk === key;
return (
<>
<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>
<TickBox
state={blockIncluded(f, change)}
label={t("changes.includeBlock")}
onChange={() => toggleBlock(f, change)}
/>
</>
);
}
: undefined;
// What Commit sends: whole files as `paths`, and for a partly ticked file
// its ticked blocks as `hunks` — read fresh at the default context, so the
// headers are the ones the server's own re-read will find.
const selection = async () => {
const paths = tickedPaths.filter((p) => !excluded.get(p)?.size);
const hunks: Record<string, { hunk: string; blocks: number[] }[]> = {};
for (const p of tickedPaths.filter((p) => excluded.get(p)?.size)) {
const r = await api.lanes.changes(target, {
range: "work",
path: p,
context: CONTEXT_STEPS[0],
});
const f = r.available ? r.files.find((x) => x.path === p) : undefined;
const picks = new Map<string, number[]>();
for (const [c, key] of blockKeys(f?.hunks ?? [])) {
if (excluded.get(p)?.has(key)) continue;
picks.set(c.hunk.header, [...(picks.get(c.hunk.header) ?? []), c.block]);
}
if (picks.size) hunks[p] = [...picks].map(([hunk, blocks]) => ({ hunk, blocks }));
}
return { paths, hunks: Object.keys(hunks).length ? hunks : undefined };
};
// Commit (and Push): the ticked files, `git commit --only` on the server.
const commitTicked = async (push = false) => {
// Amend rewrites the last commit: asked, like every history rewrite.
if (
amend &&
!(await confirmDialog({
title: t("changes.amendTitle"),
message: t("changes.amendConfirm", {
subject: git?.available ? git.subject : "",
}),
confirmLabel: t("changes.amend"),
}))
) {
return;
}
const lane = laneId;
let committed = false;
setBusy(true);
setError(null);
try {
const { paths, hunks } = await selection();
const part = hunks ? { hunks, context: CONTEXT_STEPS[0] } : {};
if (amend) {
// No message keeps the old one (`--no-edit`); no file ticked rewords.
// `sha`: the commit the dialog named (ESTALE if HEAD moved since).
const head = git?.available ? git.head : null;
await api.lanes.gitAction(target, "amend", {
...(message.trim() ? { message: message.trim() } : {}),
...(head ? { sha: head } : {}),
paths,
...part,
});
} else {
await api.lanes.gitAction(target, "commit", { message: message.trim(), paths, ...part });
}
committed = true;
if (laneNow.current !== lane) return;
// Cleared before the push: a failed push must not invite a second commit.
setMessage("");
setAmend(false);
if (push) await api.lanes.gitAction(target, "push", {});
refreshGit();
await load();
} catch (err) {
if (laneNow.current !== lane) return;
const why = err instanceof Error ? err.message : String(err);
setError(committed ? t("changes.pushFailed", { error: why }) : why);
if (committed) {
refreshGit();
void load();
}
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// ~10 s: the server runs `claude -p` on the staged diff. Its own flag, not
// `busy`, so staging stays usable while it thinks.
const suggestMessage = async () => {
const lane = laneId;
setSuggesting(true);
try {
const { message: suggested } = await api.lanes.suggestCommitMessage(target, tickedPaths);
if (laneNow.current === lane) setMessage(suggested);
} catch (err) {
if (laneNow.current === lane) toast.error(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setSuggesting(false);
}
};
const rereadAll = () => {
refreshGit();
// The tree cache is dropped whole: after a switch any folder may differ.
setTreeChildren({});
setExpandedDirs(new Set());
setReread((n) => n + 1);
return load();
};
const [rereading, runReread] = useSpin(rereadAll);
// Files tab transfers (issue #78): a download is a plain attachment link; an
// upload only creates, into the folder it was asked for ("" = the root).
const uploadInput = useRef<HTMLInputElement>(null);
const uploadDir = useRef("");
const [, setUploading] = useState(false);
// The File Explorer (the toolbar button where Upload was); the tree is
// re-read when it closes after changing anything.
const [explorer, setExplorer] = useState<{ path: string } | null>(null);
const explorerChanged = useRef(false);
const download = (path: string) => {
const a = document.createElement("a");
a.href = api.lanes.gitDownloadUrl(target, path);
a.download = path.split("/").pop() || path;
a.click();
};
const pickUpload = (dir: string) => {
uploadDir.current = dir;
uploadInput.current?.click();
};
const upload = async (files: File[]) => {
if (!files.length) return;
const lane = laneId;
const dir = uploadDir.current;
setUploading(true);
let saved = 0;
try {
for (const f of files) {
try {
await api.lanes.gitUpload(target, dir, f);
saved++;
} catch (err) {
toast.error(
t("changes.uploadFailed", {
name: f.name,
message: err instanceof Error ? err.message : String(err),
})
);
}
}
} finally {
if (laneNow.current === lane) {
setUploading(false);
if (saved) {
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
// Re-read the level it landed in, and open it.
void api.lanes
.gitTree(target, dir)
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
.catch(() => undefined);
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
}
}
}
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
const dirty = draft !== null && draft !== text?.lines.join("\n");
dirtyRef.current = dirty;
useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange);
dirtyChangeRef.current = onDirtyChange;
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
// Unmounted with an edit open (the panel closed or moved on, after the
// Workspace asked): nothing is dirty any more.
useEffect(() => () => dirtyChangeRef.current?.(false), []);
const files = data?.available ? data.files : [];
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
// What Commit sends: the ticked files still listed, in list order. Never a
// conflicted one (they have no checkbox).
const tickedPaths =
range === "work"
? files.filter((f) => ticked.has(f.path) && !f.conflicted).map((f) => f.path)
: [];
const canCommit =
!busy &&
!suggesting &&
(amend
? tickedPaths.length > 0 || !!message.trim()
: tickedPaths.length > 0 && !!message.trim());
// A newly listed file takes its default — a tracked change ticked, an
// unversioned or never-commit one not — and a file gone from the list
// leaves the set. Only the full overview list may say "gone".
useEffect(() => {
if (!data?.available || data.range !== "work" || mode !== "overview") return;
const seen = seenPaths.current;
setTicked((prev) => {
const next = new Set<string>();
for (const f of data.files) {
const on = seen?.has(f.path) ? prev.has(f.path) : !f.untracked && !f.conflicted;
if (on) next.add(f.path);
}
return next;
});
seenPaths.current = new Set(data.files.map((f) => f.path));
setExcluded((prev) => new Map([...prev].filter(([p]) => seenPaths.current!.has(p))));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (data?.available && data.range === range && (mode === "overview" || mode === "review")) {
setListed(data.files.map((f) => f.path));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
const dropExcluded = (paths: string[]) =>
setExcluded((prev) => {
if (!paths.some((p) => prev.has(p))) return prev;
const next = new Map(prev);
for (const p of paths) next.delete(p);
return next;
});
// A file's commit box: all of it, none of it, or some of its blocks.
const fileTick = (path: string): boolean | "some" =>
!ticked.has(path) ? false : excluded.get(path)?.size ? "some" : true;
const blockIncluded = (f: LaneChangeFile, change: LaneDiffChange) => {
const key = blockKeys(f.hunks ?? []).get(change);
return ticked.has(f.path) && !(key && excluded.get(f.path)?.has(key));
};
// One block's checkbox. In an unticked file it takes just that block, as
// JetBrains does; leaving every block out unticks the file.
const toggleBlock = (f: LaneChangeFile, change: LaneDiffChange) => {
const keys = blockKeys(f.hunks ?? []);
const key = keys.get(change);
if (!key) return;
const all = [...keys.values()];
const out = ticked.has(f.path) ? new Set(excluded.get(f.path)) : new Set(all);
if (!out.delete(key)) out.add(key);
const none = all.every((k) => out.has(k));
tick(f.path, !none);
setExcluded((prev) => {
const next = new Map(prev);
if (none || out.size === 0) next.delete(f.path);
else next.set(f.path, out);
return next;
});
};
const tick = (path: string, on: boolean) => {
dropExcluded([path]);
setTicked((prev) => {
const next = new Set(prev);
if (on) next.add(path);
else next.delete(path);
return next;
});
};
// A folder's or a group's box: every file in it, whole, on or off.
const tickAll = (rows: LaneChangeFile[], on: boolean) => {
dropExcluded(rows.map((f) => f.path));
setTicked((prev) => {
const next = new Set(prev);
for (const f of rows) {
if (on) next.add(f.path);
else next.delete(f.path);
}
return next;
});
};
// Commit File…: that file alone, and the message box to type into.
const commitFile = (path: string) => {
setTicked(new Set([path]));
setExcluded(new Map());
messageRef.current?.focus();
};
// Copy as Patch: the file's diff read for its path, as `git apply` takes it.
const copyPatch = async (p: string) => {
try {
const r = await api.lanes.changes(target, {
range,
...(range === "commit" && sha ? { sha } : {}),
path: p,
context: 3,
});
const f = r.available ? r.files.find((x) => x.path === p) : undefined;
if (!f?.hunks?.length) {
toast.error(t("changes.noPatch"));
return;
}
await copyText(toPatch(f));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
// "vs base" needs a base, and means nothing on the base branch itself. An
// older server reports no `base` at all: keep the toggle as it was.
const base = git?.available ? git.base : undefined;
const showBranchRange =
base === undefined || (base !== null && !(git?.available && git.branch === base));
// The armed path left the list (committed, reverted, lane switched): disarm,
// so a later reappearance starts from the first click again.
useEffect(() => {
if (!showBranchRange && range === "branch") setRange("work");
}, [showBranchRange, range]);
useEffect(() => {
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
setConfirmDiscard(null);
}
}, [data, files, confirmDiscard]);
// A tree or quick-open pick opens a changed file on its Changes like any
// other, and an unchanged one — no diff to show — as the whole file, in the
// editor. With an edit open it would drop it, so a dirty draft asks first.
const openWhole = async (p: string) => {
if (!(await confirmDrop())) return;
showNow(p);
// The overview's list, or — with one file open, the list is that file —
// the working-copy facts' (capped) list of changed paths.
const changed = (git?.available ? git.files : []).map((f) => f.path);
if ([...files.map((f) => f.path), ...changed].includes(p)) return;
editWanted.current = editableFile(range, p);
setWhole(true);
};
// Jump to Source: the whole file, changed or not.
const jumpToSource = async (p: string) => {
if (!(await confirmDrop())) return;
showNow(p);
editWanted.current = editableFile(range, p);
setWhole(true);
};
// The Files mode's body. At full width it lives in the sidebar instead, so
// the Files button is gone there and the main area never shows it twice.
const browsePane = (
<div data-testid="lane-changes-browse">
{/* Pinned: the finder is what you reach for after scrolling a
long tree, not only from its top. */}
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-1 pb-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("changes.quickOpen")}
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
<FolderOpen className="h-3.5 w-3.5" />
</IconButton>
<FileExplorer
open={!!explorer}
laneId={laneId}
session={session}
source={source}
initialPath={explorer?.path ?? ""}
onChanged={() => (explorerChanged.current = true)}
onClose={() => {
setExplorer(null);
if (explorerChanged.current) {
explorerChanged.current = false;
void rereadAll();
}
}}
/>
<input
ref={uploadInput}
type="file"
multiple
hidden
data-testid="lane-changes-upload-input"
onChange={(e) => {
const files = [...(e.target.files ?? [])];
e.target.value = "";
void upload(files);
}}
/>
{!hits && (
<IconButton
label={
expandedDirs.size > 0
? t("changes.collapseAllFolders")
: t("changes.expandAllFolders")
}
disabled={treeBusy}
onClick={() =>
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
}
>
{expandedDirs.size > 0 ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</IconButton>
)}
</div>
{hits ? (
<ul className="space-y-0.5">
{hits.map((p) => (
<li key={p}>
<button
type="button"
onClick={() => void openWhole(p)}
draggable
onDragStart={(ev) => dragProjectPath(ev, p)}
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
>
{p}
</button>
</li>
))}
{hits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
)}
</ul>
) : (
<ul className="space-y-0.5">
<TreeNodes
path=""
depth={0}
children={treeChildren}
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null}
/>
</ul>
)}
</div>
);
return (
<div
data-testid="lane-changes"
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
onKeyDown={(e) => {
if (e.key !== "F7" || (mode !== "file" && mode !== "review")) return;
e.preventDefault();
stepDiff(e.shiftKey ? -1 : 1);
}}
>
{menus}
{/* Full width has room for an explorer: the tree stays beside the file
it opened, instead of being a mode you leave to read one. */}
{full && (
<aside
data-testid="lane-changes-sidebar"
className="flex w-64 shrink-0 flex-col overflow-y-auto border-r border-border-light pr-2"
>
{browsePane}
</aside>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<LaneGitHeader
laneId={laneId}
session={session}
source={source}
git={git}
onChanged={rereadAll}
onNewLane={onNewLane}
/>
{/* ONE wrapping row: where you are (mode) then how you are looking at it
(range, layout, context, whole file), refresh last. Each group is
its own inline box, so a narrow panel wraps BETWEEN groups — never
through one, which is what made the old single row unreadable. */}
<div className="mb-2 flex flex-wrap items-center gap-1.5">
<div className="contents">
{mode === "file" ? (
<button
type="button"
onClick={goBack}
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
>
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{/* Files is the whole repo's tree; at full width it is the
sidebar already, so no button. */}
{(full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => {
// The History button is the branch's; Show History, a file's.
if (m === "history" && historyFile) {
setHistoryFile(null);
setCommits(null);
}
setMode(m);
}}
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
mode === m
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`changes.mode.${m}`)}
</button>
))}
</div>
)}
<button
type="button"
onClick={runReread}
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 || rereading ? "animate-spin" : ""}`} />
</button>
</div>
{mode !== "history" && mode !== "browse" && (
<div className="contents">
{/* With nothing to compare against, "Uncommitted" alone would be a
toggle with one side: none is drawn, unless a commit is open —
then Uncommitted is the way back. */}
{(showBranchRange || (range === "commit" && sha)) && (
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(showBranchRange ? (["work", "branch"] as const) : (["work"] as const)).map(
(r) => (
<button
key={r}
type="button"
aria-pressed={range === r}
onClick={() => {
setRange(r);
setSha(null);
}}
title={t(`changes.range.${r}Hint`, {
base: base || t("changes.range.baseBranch"),
})}
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}`, { base: base || t("changes.range.baseBranch") })}
</button>
)
)}
{range === "commit" && sha && (
<span
data-testid="lane-changes-commit-chip"
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
>
{sha.slice(0, 7)}
</span>
)}
</div>
)}
{(mode === "file" || mode === "review") && (
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
{mode === "review" && (
<IconButton
label={allCollapsed ? t("changes.expandAll") : t("changes.collapseAll")}
disabled={files.length === 0}
onClick={() =>
setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path)))
}
>
{allCollapsed ? (
<ChevronsUpDown className="h-3.5 w-3.5" />
) : (
<ChevronsDownUp className="h-3.5 w-3.5" />
)}
</IconButton>
)}
<IconButton
label={t(`changes.layout.${layout === "unified" ? "split" : "unified"}`)}
onClick={() => setLayout(layout === "unified" ? "split" : "unified")}
>
{layout === "unified" ? (
<Columns2 className="h-3.5 w-3.5" />
) : (
<Rows3 className="h-3.5 w-3.5" />
)}
</IconButton>
{mode === "file" && (
<>
{/* Context walks BOTH ways: an expanded diff that cannot
be collapsed again leaves the reader re-opening the
file to get the tight view back. */}
<IconButton
label={t("changes.collapseContext")}
disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
onClick={() =>
setContext(
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
CONTEXT_STEPS[0]
)
}
>
<FoldVertical className="h-3.5 w-3.5" />
</IconButton>
<IconButton
label={t("changes.expandContext")}
disabled={whole || draft !== null || context >= LAST_CONTEXT}
onClick={() =>
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
}
>
<UnfoldVertical className="h-3.5 w-3.5" />
</IconButton>
</>
)}
</div>
)}
{(mode === "file" || mode === "review") && (
<div
data-testid="lane-changes-nav"
className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5"
>
<IconButton label={t("changes.prevDiff")} onClick={() => stepDiff(-1)}>
<ChevronUp className="h-3.5 w-3.5" />
</IconButton>
<IconButton label={t("changes.nextDiff")} onClick={() => stepDiff(1)}>
<ChevronDown className="h-3.5 w-3.5" />
</IconButton>
{mode === "file" && (
<>
<IconButton
label={t("changes.prevFile")}
disabled={fileAt <= 0}
onClick={() => stepFile(-1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</IconButton>
<IconButton
label={t("changes.nextFile")}
disabled={fileAt < 0 || fileAt >= listed.length - 1}
onClick={() => stepFile(1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</IconButton>
</>
)}
</div>
)}
{mode === "file" && (
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{([false, true] as const).map((w) => (
<button
key={String(w)}
type="button"
aria-pressed={whole === w}
onClick={() => {
if (whole === w) return;
// Both views of a working file are the same editor: the
// draft goes along, unsaved or not.
if (editable && draft !== null) return setWhole(w);
// Back to Changes drops an open edit, so unsaved text asks first.
void confirmDrop().then((ok) => {
if (!ok) return;
if (w) editWanted.current = editableFile(range, openFile ?? "", current);
else setDraft(null);
setWhole(w);
});
}}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
whole === w
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(w ? "changes.wholeFile" : "changes.changesView")}
</button>
))}
</div>
)}
</div>
)}
</div>
{error && (
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
{error}
</p>
)}
{git?.available && git.merging && (
<div
data-testid="lane-merge-banner"
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
>
<span className="min-w-0 flex-1">
{git.conflicts
? t("changes.mergeBanner", { count: git.conflicts, op: git.operation ?? "merge" })
: t("changes.mergeReady", { op: git.operation ?? "merge" })}
</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", { op: git.operation ?? "merge" })}
</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", { op: git.operation ?? "merge" })
: t("changes.mergeAbort", { op: git.operation ?? "merge" })}
</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">
{historyFile && (
<li
data-testid="lane-changes-history-file"
className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary"
>
<span className="min-w-0 flex-1 truncate">
{t("changes.historyOfFile", { path: historyFile })}
</span>
<button
type="button"
onClick={() => {
setHistoryFile(null);
setCommits(null);
}}
className="shrink-0 rounded px-1 text-fg-muted hover:bg-surface-2 hover:text-fg-primary"
>
{t("changes.historyWholeBranch")}
</button>
</li>
)}
{historyBranches.length > 1 && (
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
<span className="shrink-0">{t("changes.historyOf")}</span>
<select
data-testid="lane-changes-history-ref"
value={historyRef ?? ""}
onChange={(e) => {
setHistoryRef(e.target.value || null);
setCommits(null);
}}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="">{git?.available ? git.branch : "HEAD"}</option>
{historyBranches
.filter((b) => !(git?.available && b === git.branch))
.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</li>
)}
{(() => {
const list = commits ?? [];
const graph = commitGraph(list);
return list.map((c, i) => (
<li key={c.sha}>
<button
type="button"
onClick={() =>
historyFile ? openAtCommit(historyFile, c.sha) : viewCommit(c.sha)
}
onContextMenu={(e) => commitCtx.open(e, c)}
style={{ height: GRAPH_ROW }}
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
>
<GraphCell row={graph.rows[i]!} width={graph.width} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</span>
</button>
</li>
));
})()}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
{commits && commits.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
)}
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
<li>
<button
type="button"
data-testid="lane-changes-history-more"
disabled={historyLoading}
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
>
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
</button>
</li>
)}
</ul>
) : mode === "browse" && !full ? (
browsePane
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
<p className="text-[11px] text-fg-muted">
{t(source ? "changes.unavailableFolder" : "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. */}
{range === "work" && !opOpen && files.some((f) => !f.conflicted) && (
<div
data-testid="lane-changes-actions"
className="sticky top-0 z-10 mb-1.5 flex flex-wrap items-center gap-1.5 border-b border-border-light bg-surface-1 pb-2"
>
{/* One row when there is room; in a narrow panel the buttons
wrap below rather than squeeze the message to a few
characters (its min width is what triggers the wrap). */}
<div
data-testid="lane-changes-commit"
className="flex min-w-[14rem] flex-1 items-center gap-1.5"
>
<input
ref={messageRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && canCommit) void commitTicked();
}}
placeholder={t(
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
)}
aria-label={t(
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
data-testid="lane-changes-suggest-message"
disabled={suggesting || tickedPaths.length === 0}
onClick={() => void suggestMessage()}
title={t("changes.suggestMessage")}
aria-label={t("changes.suggestMessage")}
className="shrink-0 flex items-center rounded border border-border-light px-1 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{suggesting ? (
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
) : (
<Sparkles className="h-3 w-3" aria-hidden />
)}
</button>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
<input
type="checkbox"
data-testid="lane-changes-amend"
checked={amend}
disabled={headPushed}
onChange={(e) => setAmend(e.target.checked)}
/>
{t("changes.amend")}
</label>
<button
type="button"
disabled={!canCommit}
onClick={() => void commitTicked()}
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"
>
{amend
? t("changes.amend")
: t("changes.commitTicked", { count: tickedPaths.length })}
</button>
<button
type="button"
disabled={!canCommit}
onClick={() => void commitTicked(true)}
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(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
</button>
</div>
</div>
)}
{/* The work range splits like JetBrains' Commit window: tracked
changes (staged or not — the checkbox picks, not the index),
then the files git does not know yet. */}
<div data-testid="lane-changes-overview">
{(range === "work"
? [
{ key: "conflicts", rows: files.filter((f) => f.conflicted) },
{ key: "changes", rows: files.filter((f) => !f.untracked && !f.conflicted) },
{
key: "unversioned",
rows: files.filter((f) => f.untracked && !f.conflicted),
},
]
: [{ key: "all", rows: files }]
).map(
(g) =>
g.rows.length > 0 && (
<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 px-1">
{g.key !== "conflicts" && (
<TickBox
state={tickState(g.rows, fileTick)}
label={t("changes.tickFolder", {
path: t(`changes.${g.key}Group`),
})}
onChange={(on) => tickAll(g.rows, on)}
/>
)}
<button
type="button"
aria-expanded={!treeCollapsed.has(`${g.key}:`)}
onClick={() => toggleTreeDir(`${g.key}:`)}
className="flex min-w-0 flex-1 items-center gap-1.5 py-0.5 text-left text-[10px] font-semibold uppercase tracking-wide text-fg-secondary hover:text-fg-primary"
>
{treeCollapsed.has(`${g.key}:`) ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
{t(`changes.${g.key}Group`)}
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
{g.rows.length}
</span>
</button>
</div>
)}
{!treeCollapsed.has(`${g.key}:`) && (
<ul data-group={g.key} className="space-y-0.5">
{/* Folders like JetBrains' commit view: a long
change-set reads by area, not as one flat list. */}
<ChangeTreeNodes
dir={changeTree(g.rows)}
depth={g.key === "all" ? 0 : 1}
group={g.key}
collapsed={treeCollapsed}
onToggle={toggleTreeDir}
renderFile={(f, name, depth) => (
<ChangeRow
key={f.path}
f={f}
name={name}
depth={depth}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
onMenu={fileCtx.open}
ticked={fileTick(f.path)}
onTick={
g.key === "changes" || g.key === "unversioned"
? tick
: undefined
}
/>
)}
folderTick={
g.key === "changes" || g.key === "unversioned"
? (rows, path) => (
<TickBox
state={tickState(rows, fileTick)}
label={t("changes.tickFolder", { path })}
onChange={(on) => tickAll(rows, on)}
/>
)
: undefined
}
/>
</ul>
)}
</div>
)
)}
</div>
</>
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
<LaneConflictView
laneId={laneId}
session={session}
source={source}
path={openFile}
onResolved={() => {
refreshGit();
void load();
goBack();
}}
onEdit={() => {
setRawEdit(true);
editWanted.current = true;
setWhole(true);
}}
/>
) : mode === "file" ? (
current || (whole && text) || openImage ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-1 flex items-baseline gap-1.5">
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
{openFile}
</span>
{current && (
<Delta
added={current.added}
deleted={current.deleted}
binary={current.binary}
/>
)}
{range === "work" && !openImage && editable && (
<button
type="button"
aria-pressed={annotate}
onClick={() => void toggleAnnotate()}
className={`flex shrink-0 items-center gap-1 self-center rounded border px-1.5 py-0.5 text-[11px] hover:bg-surface-2 ${
annotate
? "border-accent text-accent"
: "border-border-light text-fg-secondary hover:text-fg-primary"
}`}
>
{t("changes.annotate")}
</button>
)}
{whole && text && !dirty && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
aria-pressed={mdPreview}
onClick={() => setMdPreview((v) => !v)}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
>
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{range !== "commit" && openFile && (
<button
type="button"
data-testid="lane-file-download"
onClick={() => download(openFile)}
title={t("changes.download")}
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"
>
<Download className="h-3 w-3" />
{t("changes.download")}
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* A working file opens in the editor; the read-only diff
(Whole file off) keeps an Edit button back into it. */}
{range !== "commit" &&
!openImage &&
(draft === null && whole && text && !text.sha ? (
<span
data-testid="lane-file-noedit"
className="shrink-0 self-center text-[10px] text-status-warning"
>
{t(noEditReason(text))}
</span>
) : draft === null && whole ? null : draft === null ? (
<button
type="button"
data-testid="lane-file-edit"
disabled={busy}
onClick={() => {
// The file is not loaded yet: the editor opens as
// soon as the whole-file read lands.
editWanted.current = true;
setWhole(true);
}}
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Pencil className="h-3 w-3" />
{t("changes.edit")}
</button>
) : (
<>
<button
type="button"
data-testid="lane-file-save"
disabled={busy || !dirty}
onClick={() => void saveDraft()}
className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40"
>
{t("changes.save")}
</button>
<button
type="button"
data-testid="lane-file-cancel"
disabled={busy || !dirty}
onClick={() =>
void confirmDrop().then(
(ok) => ok && setDraft(text ? text.lines.join("\n") : null)
)
}
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
>
{t("changes.cancelEdit")}
</button>
</>
))}
</div>
{openImage ? (
<ImageView
path={openImage}
src={api.lanes.gitRawUrl(
target,
openImage,
range === "commit" && sha ? sha : "work"
)}
/>
) : !mdPreview &&
(whole || editable) &&
(draft !== null || (editable && text === null)) ? (
// The editor, once both texts are in: not the diff first.
draft === null || (baseText === null && range !== "commit") ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : (
<Suspense
fallback={<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>}
>
<DiffEditor
key={`${openFile}:${layout}:${whole}`}
layout={layout}
collapse={!whole}
revertLabel={t("changes.revertBlock")}
value={draft}
original={baseText ?? draft}
lang={langFor(openFile ?? "")}
label={t("changes.editorLabel", { path: openFile })}
onChange={setDraft}
// Cursor's Add to Chat: `@path#La-b`, Claude Code's
// own line-range mention, pasted like a comment. The
// leading space keeps it a mention after a typed word.
onAddToChat={(a, b) => {
const at = a === b ? `L${a}` : `L${a}-${b}`;
if (!comment(` @${openFile}#${at} `))
toast.error(t("changes.commentNoPane"));
}}
onSave={() => {
if (!busy) void saveDraft();
}}
// The same Undo / Keep as the diff, on the file on
// disk — so only while the editor holds no unsaved text.
onBlock={
current && hunkActable(current) && !dirty && !busy
? (action: BlockAction, b: EditorBlock) => {
const change = gitChangeFor(current, b);
if (!change) return setError(t("changes.blockNotInDiff"));
if (action === "include") toggleBlock(current, change);
else void actHunk("discard-hunk", current.path, change);
}
: undefined
}
blockIncluded={(b: EditorBlock) => {
const change = current && gitChangeFor(current, b);
return change ? blockIncluded(current, change) : false;
}}
blame={annotate && blame?.path === openFile ? blame : undefined}
onBlameClick={viewCommit}
navRef={editorNav}
/>
</Suspense>
)
) : whole ? (
text && mdPreview ? (
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
<MarkdownPreview text={text.lines.join("\n")} />
</div>
) : text ? (
<LaneFileView
path={text.path}
lines={text.lines}
truncated={text.truncated}
fill
/>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
)
) : current ? (
<LaneDiffView
file={current}
layout={layout}
fill
hunkActions={whole ? undefined : hunkActions(current)}
onComment={comment}
/>
) : null}
</div>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.fileGone")}</p>
)
) : (
<div data-testid="lane-changes-review" className="space-y-3">
{files.map((f) => {
const shut = collapsed.has(f.path);
return (
<div
key={f.path}
data-review-file=""
className="rounded border border-border-light"
>
{/* The whole header folds the file; only the PATH opens it.
A header that opened the file on any click made folding a
target-practice exercise against one chevron. */}
<div
role="button"
tabIndex={0}
onClick={() => toggleCollapsed(f.path)}
onContextMenu={(e) => fileCtx.open(e, f)}
onKeyDown={(e) => {
// Only the header's own keys: Enter on the path button
// inside bubbles here and must open, not fold.
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCollapsed(f.path);
}
}}
aria-expanded={!shut}
aria-label={
shut
? t("changes.expandFile", { path: f.path })
: t("changes.collapseFile", { path: f.path })
}
data-sticky-top=""
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-2 px-2 py-1 hover:bg-surface-3 ${
shut ? "" : "border-b border-border-light"
}`}
>
<ChevronRight
className={`h-3 w-3 shrink-0 self-center text-fg-muted transition-transform ${
shut ? "" : "rotate-90"
}`}
/>
<span
className={`shrink-0 font-mono text-[11px] font-semibold ${statusClass(f.status, f.untracked)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
show(f.path);
}}
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-fg-primary hover:underline"
title={t("changes.openFile", { path: f.path })}
>
{f.path}
</button>
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
{range === "work" && (
// The header folds on click; its buttons must not.
<span
className="flex items-center gap-0.5 self-center"
onClick={(e) => e.stopPropagation()}
>
{!f.conflicted && (
<TickBox
state={fileTick(f.path)}
label={t("changes.tickFile", { path: f.path })}
onChange={(on) => tick(f.path, on)}
/>
)}
<FileActions
f={f}
busy={busy}
armed={confirmDiscard === f.path}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
vcs={!f.conflicted}
/>
</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;