daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
2308 lines
92 KiB
TypeScript
2308 lines
92 KiB
TypeScript
/**
|
||
* @file The Changes panel: the lane's code beside its console, in the five
|
||
* shapes a review actually needs —
|
||
*
|
||
* overview — every changed file, in a folder tree (JetBrains' commit view),
|
||
* with its +/- counts and its stage/discard actions; click one
|
||
* to open it
|
||
* review — every file's full diff, stacked, for reading a whole change-set
|
||
* file — one file, as a diff or as its whole content (issue #35); a
|
||
* working file is always the editor (`DiffEditor`, diffed
|
||
* against HEAD / the branch's merge base, unified or side by
|
||
* side): Changes folds the unchanged lines away, Whole file
|
||
* shows them; any other file opens on its read-only diff; a
|
||
* whole markdown file can also be read rendered (Preview), and an
|
||
* image file is shown as the picture (`GET …/git/raw`)
|
||
* history — recent commits; opening one shows that commit's diff
|
||
* browse — quick-open by path, or the repo's tracked file tree (the
|
||
* mode switcher's Files button; at full width the sidebar)
|
||
*
|
||
* and three ranges: `work` (uncommitted, what the agent has touched right
|
||
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
|
||
* `commit` (one commit, reached from history).
|
||
*
|
||
* Only the overview polls. Review, file, history and browse are a deliberate
|
||
* read of a frozen moment — re-fetching under the reader's scroll position is
|
||
* worse than a slightly stale view, and those are the expensive shapes.
|
||
*
|
||
* Rendering lives in LaneDiffView.tsx. Every write goes through
|
||
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
|
||
* before the server is told `confirm: true` — it is the one action in this
|
||
* panel git cannot undo.
|
||
*
|
||
* During a merge (issue #45) a banner offers **Complete merge** (only once no
|
||
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
|
||
* conflicted files get their own group with Keep mine / Take theirs / Mark
|
||
* resolved instead of stage — staging one blind would mark it resolved.
|
||
*
|
||
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
|
||
* a toast says which tab took it, and a console not on screen takes nothing.
|
||
*
|
||
* An unsaved whole-file edit is never dropped silently: Back, another file,
|
||
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
|
||
* reloading the browser tab asks the browser's own question, and
|
||
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
|
||
* switching its tab, or moving it to another workspace or session. Two-click
|
||
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ChevronsDownUp,
|
||
ChevronsUpDown,
|
||
Check,
|
||
Code2,
|
||
Columns2,
|
||
Eye,
|
||
EyeOff,
|
||
FileText,
|
||
FoldVertical,
|
||
Folder,
|
||
Minus,
|
||
Pencil,
|
||
Plus,
|
||
RefreshCw,
|
||
Rows3,
|
||
Sparkles,
|
||
Trash2,
|
||
UnfoldVertical,
|
||
} from "lucide-react";
|
||
import { api, gitTarget } from "../../lib/api";
|
||
import { pasteToLane } from "../../lib/panePaste";
|
||
import { confirmDiscard as askDiscard } from "../../lib/confirm";
|
||
import { copyText } from "../../lib/clipboard";
|
||
import { isSubmitEnter } from "../../lib/keys";
|
||
import { toast } from "../../lib/toast";
|
||
import { useArmed } from "../../hooks/useArmed";
|
||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||
import {
|
||
LaneDiffView,
|
||
LaneFileView,
|
||
langFor,
|
||
type DiffLayout,
|
||
type LaneDiffChange,
|
||
} from "./LaneDiffView";
|
||
import { LaneConflictView } from "./LaneConflictView";
|
||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||
import type {
|
||
LaneChangeFile,
|
||
LaneChangeRange,
|
||
LaneChanges,
|
||
LaneCommit,
|
||
LaneTreeEntry,
|
||
} from "../../lib/types";
|
||
|
||
/** How often the overview re-reads. Summary only — no patch text. */
|
||
const OVERVIEW_REFRESH_MS = 15_000;
|
||
/** Context steps the expand button walks through, then the whole file. */
|
||
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
|
||
|
||
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
|
||
|
||
type Mode = "overview" | "review" | "file" | "history" | "browse";
|
||
|
||
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
|
||
function statusClass(status: string, untracked: boolean) {
|
||
if (untracked) return "text-status-success";
|
||
const code = status.trim()[0];
|
||
if (code === "A") return "text-status-success";
|
||
if (code === "D") return "text-status-danger";
|
||
if (code === "R") return "text-accent";
|
||
return "text-status-warning";
|
||
}
|
||
|
||
/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file
|
||
* has no line delta at all, so it says `bin` rather than leaving the row's
|
||
* right edge blank, which reads as "changed by nothing". */
|
||
function Delta({
|
||
added,
|
||
deleted,
|
||
binary,
|
||
}: {
|
||
added: number | null;
|
||
deleted: number | null;
|
||
binary?: boolean;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
if (binary)
|
||
return <span className="shrink-0 font-mono text-[10px] text-fg-muted">{t("changes.bin")}</span>;
|
||
return (
|
||
<span className="shrink-0 font-mono text-[10px]">
|
||
{added ? <span className="text-status-success">+{added}</span> : null}
|
||
{added && deleted ? " " : null}
|
||
{deleted ? <span className="text-status-danger">−{deleted}</span> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||
// CodeMirror is loaded only once a file is edited.
|
||
const DiffEditor = lazy(() => import("./DiffEditor"));
|
||
|
||
/** A file that opens straight in the editor: a working file with text — not a
|
||
* commit's, an image, a binary, a deleted file or a merge conflict (which
|
||
* opens in the conflict view). */
|
||
function editableFile(range: LaneChangeRange, path: string, f?: LaneChangeFile) {
|
||
if (range === "commit" || IMAGE_FILE.test(path)) return false;
|
||
return !f || (!f.binary && !f.conflicted && !f.status.includes("D"));
|
||
}
|
||
|
||
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
|
||
function ImageView({ src, path }: { src: string; path: string }) {
|
||
const { t } = useTranslation("lanes");
|
||
const [failed, setFailed] = useState(false);
|
||
useEffect(() => setFailed(false), [src]);
|
||
return (
|
||
<div
|
||
data-testid="lane-file-image"
|
||
className="flex min-h-0 flex-1 items-center justify-center overflow-auto rounded border border-border-light bg-surface-0 p-4"
|
||
>
|
||
{failed ? (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.imageFailed")}</p>
|
||
) : (
|
||
<img
|
||
src={src}
|
||
alt={path}
|
||
onError={() => setFailed(true)}
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Why a whole-file read came back without an edit stamp (`sha`): the i18n
|
||
* key naming it, so the reader is never left with a button that does nothing. */
|
||
function noEditReason(text: { binary?: boolean; truncated: boolean }) {
|
||
if (text.binary) return "changes.noEditBinary";
|
||
if (text.truncated) return "changes.noEditTooBig";
|
||
return "changes.noEdit";
|
||
}
|
||
|
||
/** The explorer's decoration for a porcelain code: one letter + its colour,
|
||
* VS Code's convention (U untracked, A added, D deleted, R renamed, M else). */
|
||
function treeMark(code: string): { letter: string; tone: string } {
|
||
// The tint fills the whole row, not just the letter, so a changed file
|
||
// stands out of a long tree at a glance.
|
||
if (code === "??") return { letter: "U", tone: "text-status-success bg-status-success/10" };
|
||
// A merge conflict (git's unmerged codes) before A/D: `AA`/`DU` are not adds or deletes.
|
||
if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(code))
|
||
return { letter: "!", tone: "text-status-danger bg-status-danger/10" };
|
||
if (code.includes("A")) return { letter: "A", tone: "text-status-success bg-status-success/10" };
|
||
if (code.includes("D")) return { letter: "D", tone: "text-status-danger bg-status-danger/10" };
|
||
if (code.includes("R")) return { letter: "R", tone: "text-status-warning bg-status-warning/10" };
|
||
return { letter: "M", tone: "text-status-warning bg-status-warning/10" };
|
||
}
|
||
|
||
/**
|
||
* One folder level of the browse tree, rendered in place and recursing into
|
||
* every expanded child — so opening a nested file no longer means navigating
|
||
* away from its siblings.
|
||
*/
|
||
function TreeNodes({
|
||
path,
|
||
depth,
|
||
children,
|
||
expanded,
|
||
onToggleDir,
|
||
onOpenFile,
|
||
onMenu,
|
||
active,
|
||
kept,
|
||
}: {
|
||
path: string;
|
||
depth: number;
|
||
children: Record<string, LaneTreeEntry[]>;
|
||
/** Paths on the never-stage list: drawn orange, whatever their git status. */
|
||
kept?: Set<string>;
|
||
expanded: Set<string>;
|
||
onToggleDir: (path: string) => void;
|
||
onOpenFile: (path: string) => void;
|
||
/** Right-click on an entry. */
|
||
onMenu?: (e: React.MouseEvent, entry: LaneTreeEntry) => void;
|
||
/** The file open beside the tree, drawn selected. */
|
||
active?: string | null;
|
||
}) {
|
||
const entries = children[path];
|
||
if (!entries) return null;
|
||
return (
|
||
<>
|
||
{entries.map((e) => {
|
||
const mark = e.status ? treeMark(e.status) : null;
|
||
const isKept = !e.dir && !!kept?.has(e.path);
|
||
return (
|
||
<li key={e.path}>
|
||
<button
|
||
type="button"
|
||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||
data-tree-kept={isKept || undefined}
|
||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||
aria-current={active === e.path ? "true" : undefined}
|
||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||
isKept
|
||
? "bg-kept/10 text-kept"
|
||
: mark
|
||
? mark.tone
|
||
: e.changed
|
||
? "text-status-warning"
|
||
: "text-fg-primary"
|
||
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
||
>
|
||
{e.dir ? (
|
||
expanded.has(e.path) ? (
|
||
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
)
|
||
) : (
|
||
<span className="w-3 shrink-0" />
|
||
)}
|
||
{e.dir ? (
|
||
<Folder className="h-3 w-3 shrink-0 text-accent" />
|
||
) : (
|
||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
)}
|
||
<span
|
||
className={`min-w-0 flex-1 truncate ${e.status?.includes("D") ? "line-through" : ""}`}
|
||
>
|
||
{e.name}
|
||
</span>
|
||
{mark ? (
|
||
<span className="shrink-0 pr-1 font-semibold" title={e.status}>
|
||
{mark.letter}
|
||
</span>
|
||
) : e.changed ? (
|
||
// A folder only says "something in here" — the dot, not a letter.
|
||
<span className="mr-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-status-warning" />
|
||
) : null}
|
||
</button>
|
||
{e.dir && expanded.has(e.path) && (
|
||
<ul>
|
||
<TreeNodes
|
||
path={e.path}
|
||
depth={depth + 1}
|
||
children={children}
|
||
expanded={expanded}
|
||
onToggleDir={onToggleDir}
|
||
onOpenFile={onOpenFile}
|
||
onMenu={onMenu}
|
||
active={active}
|
||
kept={kept}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function IconButton({
|
||
label,
|
||
onClick,
|
||
disabled,
|
||
danger,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
danger?: boolean;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
title={label}
|
||
className={`shrink-0 rounded p-0.5 disabled:opacity-40 ${
|
||
danger ? "text-fg-muted hover:text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** A hunk's Undo/Keep chip in its review bar: labelled, so the two read apart
|
||
* at a glance instead of as two grey glyphs. */
|
||
const HUNK_CHIP =
|
||
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40";
|
||
|
||
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
|
||
|
||
/** A work-range file's stage / unstage / discard buttons — the overview row's
|
||
* and the review header's. */
|
||
function FileActions({
|
||
f,
|
||
busy,
|
||
armed,
|
||
onAct,
|
||
onArm,
|
||
kept = false,
|
||
onKeep,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
busy: boolean;
|
||
armed: boolean;
|
||
onAct: FileAct;
|
||
onArm: (path: string) => void;
|
||
/** On the never-stage list: Stage all and a folder's stage skip it. */
|
||
kept?: boolean;
|
||
onKeep?: (path: string) => void;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<span className="flex shrink-0 items-center gap-0.5">
|
||
{f.staged ? (
|
||
<IconButton
|
||
label={t("changes.unstage")}
|
||
disabled={busy}
|
||
onClick={() => onAct("unstage", f.path)}
|
||
>
|
||
<Minus className="h-3 w-3" />
|
||
</IconButton>
|
||
) : null}
|
||
{f.unstaged || f.untracked ? (
|
||
<IconButton
|
||
label={t("changes.stage")}
|
||
disabled={busy}
|
||
onClick={() => onAct("stage", f.path)}
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
</IconButton>
|
||
) : null}
|
||
{onKeep && (kept || f.unstaged || f.untracked) ? (
|
||
<IconButton
|
||
label={t(kept ? "changes.allowStage" : "changes.neverStage")}
|
||
onClick={() => onKeep(f.path)}
|
||
>
|
||
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||
</IconButton>
|
||
) : null}
|
||
<IconButton
|
||
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
|
||
disabled={busy}
|
||
danger
|
||
onClick={() => {
|
||
// Two clicks, always: the first arms the row, the second is the
|
||
// only one that reaches the server (which itself refuses the
|
||
// call without `confirm: true`).
|
||
if (armed) onAct("discard", f.path, true);
|
||
else onArm(f.path);
|
||
}}
|
||
>
|
||
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
|
||
</IconButton>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
type Resolve = (path: string, side: "ours" | "theirs" | "done") => void;
|
||
|
||
/** A conflicted file's three ways out of the conflict. */
|
||
function ConflictActions({
|
||
f,
|
||
busy,
|
||
onResolve,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
busy: boolean;
|
||
onResolve: Resolve;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<span className="flex shrink-0 items-center gap-0.5">
|
||
{(["ours", "theirs"] as const).map((side) => (
|
||
<button
|
||
key={side}
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => onResolve(f.path, side)}
|
||
title={t(side === "ours" ? "changes.takeOursHint" : "changes.takeTheirsHint")}
|
||
className={`shrink-0 rounded border px-1 text-[10px] disabled:opacity-40 ${
|
||
side === "ours"
|
||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||
: "border-accent/40 text-accent hover:bg-accent/10"
|
||
}`}
|
||
>
|
||
{t(side === "ours" ? "changes.takeOurs" : "changes.takeTheirs")}
|
||
</button>
|
||
))}
|
||
<IconButton
|
||
label={t("changes.markResolved")}
|
||
disabled={busy}
|
||
onClick={() => onResolve(f.path, "done")}
|
||
>
|
||
<Check className="h-3 w-3" />
|
||
</IconButton>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** One overview row. At module scope on purpose: declared inside the panel it
|
||
* was a new component type every render, so each poll and each keystroke in
|
||
* the commit box remounted every row (focus dropped to <body>). */
|
||
function ChangeRow({
|
||
f,
|
||
range,
|
||
busy,
|
||
armed,
|
||
onOpen,
|
||
onAct,
|
||
onArm,
|
||
onResolve,
|
||
onMenu,
|
||
name,
|
||
depth = 0,
|
||
kept = false,
|
||
onKeep,
|
||
}: {
|
||
f: LaneChangeFile;
|
||
kept?: boolean;
|
||
onKeep?: (path: string) => void;
|
||
/** In the folder tree: the file's own name, and how deep it sits. */
|
||
name?: string;
|
||
depth?: number;
|
||
range: LaneChangeRange;
|
||
onResolve: Resolve;
|
||
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
|
||
busy: boolean;
|
||
/** This row's discard has been clicked once and awaits the second click. */
|
||
armed: boolean;
|
||
onOpen: (path: string) => void;
|
||
onAct: FileAct;
|
||
onArm: (path: string) => void;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<li
|
||
className="group flex items-baseline gap-1"
|
||
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
|
||
onContextMenu={(e) => onMenu(e, f)}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpen(f.path)}
|
||
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||
>
|
||
<span
|
||
className={`w-3 shrink-0 font-mono text-[11px] font-semibold ${statusClass(
|
||
f.status,
|
||
f.untracked
|
||
)}`}
|
||
>
|
||
{f.untracked ? "A" : f.status.trim()[0] || "M"}
|
||
</span>
|
||
<span
|
||
className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
|
||
kept ? "text-fg-muted line-through" : "text-fg-primary"
|
||
}`}
|
||
title={kept ? `${f.path}\n${t("changes.keptHint")}` : f.path}
|
||
>
|
||
{name ?? f.path}
|
||
</span>
|
||
{f.conflicted && (
|
||
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
|
||
)}
|
||
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
|
||
</button>
|
||
{range === "work" &&
|
||
(f.conflicted ? (
|
||
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
|
||
) : (
|
||
<FileActions
|
||
f={f}
|
||
busy={busy}
|
||
armed={armed}
|
||
onAct={onAct}
|
||
onArm={onArm}
|
||
kept={kept}
|
||
onKeep={onKeep}
|
||
/>
|
||
))}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/** One folder of the overview's change tree: its sub-folders (each a
|
||
* collapsible row with its file count), then its files. */
|
||
function ChangeTreeNodes({
|
||
dir,
|
||
depth,
|
||
group,
|
||
collapsed,
|
||
onToggle,
|
||
renderFile,
|
||
folderAction,
|
||
}: {
|
||
dir: ChangeTreeDir<LaneChangeFile>;
|
||
depth: number;
|
||
/** The overview group, so the same folder folds apart in each. */
|
||
group: string;
|
||
collapsed: Set<string>;
|
||
onToggle: (key: string) => void;
|
||
renderFile: (f: LaneChangeFile, name: string, depth: number) => React.ReactNode;
|
||
/** The folder row's own action, e.g. stage every file in it. */
|
||
folderAction?: (files: LaneChangeFile[]) => React.ReactNode;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
return (
|
||
<>
|
||
{dir.dirs.map((d) => {
|
||
const key = `${group}:${d.path}`;
|
||
const open = !collapsed.has(key);
|
||
return (
|
||
<li key={d.path}>
|
||
<div
|
||
className="group flex items-center gap-1"
|
||
style={{ paddingLeft: `${depth * 0.9}rem` }}
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
onClick={() => onToggle(key)}
|
||
title={d.path}
|
||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||
>
|
||
{open ? (
|
||
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
) : (
|
||
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
)}
|
||
<Folder className="h-3 w-3 shrink-0 text-accent" />
|
||
<span className="min-w-0 truncate">{d.name}</span>
|
||
<span className="shrink-0 font-sans text-[10px] text-fg-muted">
|
||
{t("changes.fileCount", { count: d.count })}
|
||
</span>
|
||
</button>
|
||
{folderAction?.(treeFiles(d))}
|
||
</div>
|
||
{open && (
|
||
<ul className="space-y-0.5">
|
||
<ChangeTreeNodes
|
||
dir={d}
|
||
depth={depth + 1}
|
||
group={group}
|
||
collapsed={collapsed}
|
||
onToggle={onToggle}
|
||
renderFile={renderFile}
|
||
folderAction={folderAction}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
{dir.files.map(({ name, file }) => renderFile(file, name, depth))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** History's page size, and the most the server reads (`listCommits`). */
|
||
const HISTORY_PAGE = 50;
|
||
const HISTORY_MAX = 200;
|
||
|
||
// The history's commit graph: one fixed-height row per commit so its lanes
|
||
// join up row to row. Mid-tone hues that read on dark and light themes alike.
|
||
const GRAPH_ROW = 34;
|
||
const GRAPH_COL = 12;
|
||
const GRAPH_COLORS = [
|
||
"#3b82f6",
|
||
"#22c55e",
|
||
"#f59e0b",
|
||
"#a855f7",
|
||
"#ef4444",
|
||
"#14b8a6",
|
||
"#ec4899",
|
||
"#84cc16",
|
||
];
|
||
|
||
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||
const y = (f: number) => f * GRAPH_ROW;
|
||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||
return (
|
||
<svg
|
||
aria-hidden
|
||
width={width * GRAPH_COL}
|
||
height={GRAPH_ROW}
|
||
className="shrink-0"
|
||
data-testid="lane-changes-graph"
|
||
>
|
||
{row.segs.map((g, i) => {
|
||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||
return (
|
||
<path
|
||
key={i}
|
||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||
fill="none"
|
||
stroke={hue(g.color)}
|
||
strokeWidth={1.5}
|
||
/>
|
||
);
|
||
})}
|
||
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function LaneChangesPanel({
|
||
laneId,
|
||
session,
|
||
onNewLane,
|
||
full = false,
|
||
wide = full,
|
||
onDirtyChange,
|
||
}: {
|
||
laneId: number;
|
||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||
onDirtyChange?: (dirty: boolean) => void;
|
||
/** Show and act on that session's own worktree (`claude --worktree`)
|
||
* instead of the workspace's folder. The Workspace keys the panel on it,
|
||
* so a different session is a fresh panel. */
|
||
session?: string;
|
||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||
onNewLane?: (branch?: string) => void;
|
||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||
* whole file opens straight into the editor. */
|
||
full?: boolean;
|
||
/** Half the width or more: diffs and the editor open side by side. */
|
||
wide?: boolean;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
// One read of the working copy, shared by the header and every write below:
|
||
// a stage, a discard or a commit changes the counts the header shows.
|
||
const [git, refreshGit] = useLaneGitFacts(laneId, session);
|
||
// Every git read and write below goes to the same tree as those facts.
|
||
const target = gitTarget(laneId, session);
|
||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||
const comment = useCallback(
|
||
(text: string) => {
|
||
const runId = pasteToLane(laneId, text);
|
||
if (runId) {
|
||
const slot = Number(/-([1-9])$/.exec(runId)?.[1] ?? 0);
|
||
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
|
||
}
|
||
return runId !== null;
|
||
},
|
||
[laneId, t]
|
||
);
|
||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||
// history and the tree too, not just the diff `load` re-reads.
|
||
const [reread, setReread] = useState(0);
|
||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||
const [mode, setMode] = useState<Mode>("overview");
|
||
const [openFile, setOpenFile] = useState<string | null>(null);
|
||
// An image opens as a picture, never as text or a binary diff.
|
||
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
|
||
const [sha, setSha] = useState<string | null>(null);
|
||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||
// Side by side wherever there is room for two columns; a resize starts over
|
||
// from that default, a click on the toggle overrides it until then.
|
||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||
const [whole, setWhole] = useState(false);
|
||
// A whole markdown file shown rendered instead of as source; per file.
|
||
const [mdPreview, setMdPreview] = useState(false);
|
||
useEffect(() => setMdPreview(false), [openFile]);
|
||
const [data, setData] = useState<LaneChanges | null>(null);
|
||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
|
||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||
const [query, setQuery] = useState("");
|
||
const [hits, setHits] = useState<string[] | null>(null);
|
||
const [text, setText] = useState<{
|
||
path: string;
|
||
lines: string[];
|
||
truncated: boolean;
|
||
binary?: boolean;
|
||
sha?: string;
|
||
} | null>(null);
|
||
// The whole-file editor's text while it is open; null = viewing.
|
||
const [draft, setDraft] = useState<string | null>(null);
|
||
const editWanted = useRef(false);
|
||
// The text the editor diffs against: the file at HEAD (`work`) or at the
|
||
// branch's merge base; "" for a file that is new there.
|
||
const [baseText, setBaseText] = useState<string | null>(null);
|
||
const startEdit = () => {
|
||
if (!text?.sha) return;
|
||
setDraft(text.lines.join("\n"));
|
||
};
|
||
useEffect(() => {
|
||
if (!editWanted.current || !text) return;
|
||
editWanted.current = false;
|
||
if (text.sha) startEdit();
|
||
// Asked for from the diff, refused once the file arrived: say why here,
|
||
// not with an Edit button that silently does nothing.
|
||
else setError(t(noEditReason(text)));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [text]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
// Two-click arming that disarms by itself after 3 s: the armed path, the
|
||
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
|
||
const discardArm = useArmed();
|
||
const confirmDiscard = discardArm.armed;
|
||
const setConfirmDiscard = (path: string | null) =>
|
||
path ? discardArm.arm(path) : discardArm.disarm();
|
||
const abortArm = useArmed();
|
||
const armedAbort = abortArm.armed !== null;
|
||
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
|
||
// A conflicted file opens in the conflict view; true = the plain editor.
|
||
const [rawEdit, setRawEdit] = useState(false);
|
||
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
|
||
const hunkArm = useArmed();
|
||
const armedHunk = hunkArm.armed;
|
||
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
|
||
// Files folded shut in review mode. A change-set is read file by file, and
|
||
// a file already read is in the way of the next one — so the set is what is
|
||
// COLLAPSED, and a file arriving later is open by default.
|
||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||
// The overview tree's folded groups and folders (`<group>:<folder path>`);
|
||
// everything starts open.
|
||
const [treeCollapsed, setTreeCollapsed] = useState<Set<string>>(new Set());
|
||
const toggleTreeDir = (key: string) =>
|
||
setTreeCollapsed((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(key)) next.add(key);
|
||
return next;
|
||
});
|
||
// Files the user never wants staged (a local note, a scratch folder): Stage
|
||
// all and a folder's stage skip them; git is never told — nothing is written
|
||
// to the repo. A viewer preference per workspace, like the panel's size.
|
||
const neverStageKey = `agenthub.changes.neverStage.${laneId}`;
|
||
const [neverStage, setNeverStage] = useState<Set<string>>(new Set());
|
||
useEffect(() => {
|
||
try {
|
||
const saved = JSON.parse(window.localStorage.getItem(neverStageKey) || "[]");
|
||
setNeverStage(
|
||
new Set(Array.isArray(saved) ? saved.filter((p) => typeof p === "string") : [])
|
||
);
|
||
} catch {
|
||
setNeverStage(new Set());
|
||
}
|
||
}, [neverStageKey]);
|
||
const toggleNeverStage = (path: string) =>
|
||
setNeverStage((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(path)) next.add(path);
|
||
try {
|
||
window.localStorage.setItem(neverStageKey, JSON.stringify([...next]));
|
||
} catch {
|
||
/* kept for this visit only */
|
||
}
|
||
return next;
|
||
});
|
||
// Where opening a file came FROM: its mode and how far the list was scrolled.
|
||
// Reviewing a change-set is a walk down a long list, and landing back at the
|
||
// top of it after every file is the whole cost of opening one.
|
||
const scroller = useRef<HTMLDivElement>(null);
|
||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||
const pendingScroll = useRef<number | null>(null);
|
||
const [message, setMessage] = useState("");
|
||
const [suggesting, setSuggesting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Every diff read gets a ticket; only the newest may land. A lane switch (or
|
||
// a mode/range change) re-issues the read, and without this a slow reply for
|
||
// the previous lane painted its files — and its discard buttons — over this one.
|
||
const loadSeq = useRef(0);
|
||
// The same guard for the side reads (history, tree, whole file), which only
|
||
// race across a lane switch: a reply for a lane no longer shown is dropped.
|
||
const laneNow = useRef(laneId);
|
||
laneNow.current = laneId;
|
||
const forLane =
|
||
<T,>(id: number, fn: (v: T) => void) =>
|
||
(v: T) => {
|
||
if (laneNow.current === id) fn(v);
|
||
};
|
||
const load = useCallback(async () => {
|
||
const seq = ++loadSeq.current;
|
||
setLoading(true);
|
||
try {
|
||
const result = await api.lanes.changes(target, {
|
||
range,
|
||
...(range === "commit" && sha ? { sha } : {}),
|
||
...(mode === "file" && openFile ? { path: openFile, context } : {}),
|
||
...(mode === "review" ? { full: true } : {}),
|
||
});
|
||
if (seq !== loadSeq.current) return;
|
||
setData(result);
|
||
setError(null);
|
||
} catch (err) {
|
||
if (seq !== loadSeq.current) return;
|
||
// A tracked file opened from Files that has nothing to diff: a normal
|
||
// state, not a fault. The whole-file view is the answer; no banner.
|
||
if (mode === "file" && (err as { code?: string })?.code === "ENOFILECHANGE") {
|
||
setData({
|
||
available: true,
|
||
range,
|
||
base: null,
|
||
baseRef: null,
|
||
sha,
|
||
context,
|
||
files: [],
|
||
truncated: false,
|
||
});
|
||
setError(null);
|
||
return;
|
||
}
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (seq === loadSeq.current) setLoading(false);
|
||
}
|
||
}, [laneId, range, sha, mode, openFile, context]);
|
||
|
||
useEffect(() => {
|
||
if (mode === "history" || mode === "browse") return;
|
||
void load();
|
||
// Only the cheap shape polls; see the file header.
|
||
if (mode !== "overview") return;
|
||
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
|
||
return () => window.clearInterval(timer);
|
||
}, [load, mode]);
|
||
|
||
// History and the file tree are their own reads, each fetched when its mode
|
||
// is entered rather than alongside the diff nobody is looking at.
|
||
// History reads HISTORY_PAGE commits, and Load more asks for the next page
|
||
// (up to the server's 200).
|
||
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
|
||
const [historyLoading, setHistoryLoading] = useState(false);
|
||
useEffect(() => {
|
||
if (mode !== "history") return;
|
||
setHistoryLoading(true);
|
||
void api.lanes
|
||
.gitLog(target, { limit: historyLimit })
|
||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||
}, [mode, laneId, reread, historyLimit]);
|
||
|
||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||
// stays across the switch, so the mode alone never changes.
|
||
const treeRootLoaded = "" in treeChildren;
|
||
useEffect(() => {
|
||
const wanted = full || (mode === "browse" && !query.trim());
|
||
if (!wanted || treeRootLoaded) return;
|
||
void api.lanes
|
||
.gitTree(target, "")
|
||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||
// `treeChildren` itself is the cache this fills — a dep on it would loop.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [mode, laneId, query, reread, full, treeRootLoaded]);
|
||
|
||
// A folder's children are fetched lazily, the first time it is expanded.
|
||
const toggleDir = (path: string) => {
|
||
setExpandedDirs((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(path)) next.add(path);
|
||
return next;
|
||
});
|
||
if (path in treeChildren) return;
|
||
void api.lanes
|
||
.gitTree(target, path)
|
||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||
};
|
||
|
||
const [treeBusy, setTreeBusy] = useState(false);
|
||
|
||
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
||
// `git ls-files` per folder, seconds of waiting on a large repo.
|
||
const expandAllDirs = async () => {
|
||
const lane = laneId;
|
||
setTreeBusy(true);
|
||
try {
|
||
const { levels } = await api.lanes.gitTreeAll(target);
|
||
// A lane switch mid-read must not land lane A's tree in lane B.
|
||
if (laneNow.current !== lane) return;
|
||
setTreeChildren(levels);
|
||
setExpandedDirs(new Set(Object.keys(levels).filter(Boolean)));
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setTreeBusy(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const q = query.trim();
|
||
if ((mode !== "browse" && !full) || !q) {
|
||
setHits(null);
|
||
return;
|
||
}
|
||
// `live` drops an answer that a newer query, a mode change or a lane
|
||
// switch has overtaken — a slow "main" must not overwrite "mainx".
|
||
let live = true;
|
||
const timer = window.setTimeout(() => {
|
||
void api.lanes
|
||
.gitLs(target, q)
|
||
.then((r) => live && setHits(r.paths))
|
||
.catch(() => live && setHits([]));
|
||
}, 150);
|
||
return () => {
|
||
live = false;
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [mode, laneId, query, full]);
|
||
|
||
// The open file's entry in the change list, and whether it is edited in
|
||
// place: a working file with text opens as the editor in both views.
|
||
const openEntry =
|
||
data?.available && openFile ? data.files.find((f) => f.path === openFile) : undefined;
|
||
const editable = mode === "file" && !!openFile && editableFile(range, openFile, openEntry);
|
||
// The whole-file read is a different endpoint from the diff, so it is its
|
||
// own fetch — and only while the viewer is actually showing it.
|
||
useEffect(() => {
|
||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||
void api.lanes
|
||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||
.then(
|
||
forLane(laneId, (r) =>
|
||
setText(
|
||
r.available
|
||
? {
|
||
path: r.path,
|
||
lines: r.lines,
|
||
truncated: r.truncated,
|
||
binary: r.binary,
|
||
sha: r.sha,
|
||
}
|
||
: null
|
||
)
|
||
)
|
||
)
|
||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||
// The editor's original side, read beside the working file. A file that is
|
||
// not there (new since) diffs against nothing: every line is added.
|
||
const mergeBase = data?.available ? (data.mergeBase ?? null) : null;
|
||
useEffect(() => {
|
||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||
void api.lanes.gitFile(target, openFile, rev).then(
|
||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
||
forLane(laneId, () => setBaseText(""))
|
||
);
|
||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||
|
||
useEffect(() => {
|
||
if (full && mode === "browse") setMode("overview");
|
||
}, [full, mode]);
|
||
|
||
// A lane switch must not leave the previous lane's file open — its path
|
||
// almost certainly does not exist in the new one.
|
||
useEffect(() => {
|
||
setMode("overview");
|
||
setOpenFile(null);
|
||
setSha(null);
|
||
setRange("work");
|
||
setTreeChildren({});
|
||
setExpandedDirs(new Set());
|
||
setTreeBusy(false);
|
||
setBusy(false);
|
||
setError(null);
|
||
setCommits(null);
|
||
setData(null);
|
||
setDraft(null);
|
||
setCollapsed(new Set());
|
||
// An armed discard names a PATH, not a file in a lane — carried over, one
|
||
// click would discard the same path in the lane just switched to.
|
||
setConfirmDiscard(null);
|
||
// Same for a half-typed commit message: it describes the OTHER lane's work.
|
||
setMessage("");
|
||
returnTo.current = null;
|
||
pendingScroll.current = null;
|
||
}, [laneId]);
|
||
// After the lane reset above: a workspace opens on its changes.
|
||
// Leaving the open file this way (the Workspace asked first when it was
|
||
// edited) drops it whole: a draft left behind would keep `dirty` true.
|
||
useEffect(() => {
|
||
if (full) return;
|
||
setMode("overview");
|
||
setOpenFile(null);
|
||
setWhole(false);
|
||
setDraft(null);
|
||
}, [laneId, full]);
|
||
|
||
// Restoring has to wait for the list to be TALL enough again: leaving a file
|
||
// re-fetches the mode it came from, so the first render back is an empty
|
||
// box that would swallow the scroll.
|
||
useLayoutEffect(() => {
|
||
const el = scroller.current;
|
||
const want = pendingScroll.current;
|
||
if (mode === "file" || want == null || !el) return;
|
||
if (el.scrollHeight - el.clientHeight < want) return;
|
||
el.scrollTop = want;
|
||
pendingScroll.current = null;
|
||
}, [mode, data]);
|
||
|
||
// Every way out of an open, edited file asks once; nothing else does.
|
||
const dirtyRef = useRef(false);
|
||
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
|
||
|
||
const showNow = (path: string) => {
|
||
// Opening a file FROM a file (the review list is gone by then) must not
|
||
// overwrite the list position that is still waiting to be restored.
|
||
if (mode !== "file") {
|
||
returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 };
|
||
}
|
||
setOpenFile(path);
|
||
setContext(CONTEXT_STEPS[0]);
|
||
// A file opens on its Changes. A working file is the editor folded to its
|
||
// changes (each block with a Revert); Whole file unfolds it. Anything else
|
||
// — a commit, a binary, a deleted file — is the read-only diff.
|
||
const f = data?.available ? data.files.find((x) => x.path === path) : undefined;
|
||
editWanted.current = editableFile(range, path, f);
|
||
setWhole(false);
|
||
setText(null);
|
||
setBaseText(null);
|
||
setDraft(null);
|
||
setRawEdit(false);
|
||
setMode("file");
|
||
};
|
||
|
||
const show = (path: string) => {
|
||
void confirmDrop().then((ok) => ok && showNow(path));
|
||
};
|
||
|
||
const goBackNow = () => {
|
||
const back = returnTo.current;
|
||
pendingScroll.current = back?.scroll ?? 0;
|
||
returnTo.current = null;
|
||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||
setOpenFile(null);
|
||
setWhole(false);
|
||
setDraft(null);
|
||
};
|
||
const goBack = () => {
|
||
void confirmDrop().then((ok) => ok && goBackNow());
|
||
};
|
||
|
||
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
|
||
// rewrote meanwhile comes back 409 ESTALE and the draft stays for a retry.
|
||
const saveDraft = async () => {
|
||
if (draft === null || !text?.sha) return;
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const r = await api.lanes.gitSaveFile(target, text.path, draft, text.sha);
|
||
if (laneNow.current !== lane) return;
|
||
if (r.available) {
|
||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||
}
|
||
// Saved, still editing: the editor stays on the text just written.
|
||
setDraft(r.available ? r.lines.join("\n") : null);
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// An action that resolves after a lane switch belongs to the lane left: its
|
||
// `load` is the pre-switch one and would take a fresh ticket and paint the
|
||
// old lane's files here, so everything after the await is dropped.
|
||
const act = async (
|
||
action: "stage" | "unstage" | "discard",
|
||
path: string | string[],
|
||
confirm?: boolean
|
||
) => {
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const paths = Array.isArray(path) ? path : [path];
|
||
await api.lanes.gitAction(target, action, { paths, ...(confirm ? { confirm } : {}) });
|
||
if (laneNow.current !== lane) return;
|
||
setConfirmDiscard(null);
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||
const mergeAct = async (
|
||
action: "resolve" | "merge-commit" | "merge-abort",
|
||
body: Parameters<typeof api.lanes.gitAction>[2] = {}
|
||
) => {
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await api.lanes.gitAction(target, action, body);
|
||
if (laneNow.current !== lane) return;
|
||
setArmedAbort(false);
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||
|
||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||
const commitCtx = useContextMenu<LaneCommit>();
|
||
const viewCommit = (sha: string) => {
|
||
setSha(sha);
|
||
setRange("commit");
|
||
setMode("review");
|
||
};
|
||
const copy = (text: string) => void copyText(text);
|
||
// A menu item: close its menu, then act.
|
||
const pick = (close: () => void, fn: () => void) => () => {
|
||
close();
|
||
fn();
|
||
};
|
||
const menuFile = fileCtx.menu?.item;
|
||
const menuArmed = !!menuFile && confirmDiscard === menuFile.path;
|
||
const menus = (
|
||
<>
|
||
{fileCtx.menu && menuFile && (
|
||
<ContextMenu
|
||
x={fileCtx.menu.x}
|
||
y={fileCtx.menu.y}
|
||
onClose={fileCtx.close}
|
||
testId="lane-changes-file-menu"
|
||
>
|
||
<MenuItem onSelect={pick(fileCtx.close, () => show(menuFile.path))}>
|
||
{t("changes.openFile", { path: menuFile.path.split("/").pop() })}
|
||
</MenuItem>
|
||
{range === "work" && menuFile.conflicted && (
|
||
<>
|
||
<MenuSeparator />
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "ours"))}
|
||
>
|
||
{t("changes.takeOurs")}
|
||
</MenuItem>
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "theirs"))}
|
||
>
|
||
{t("changes.takeTheirs")}
|
||
</MenuItem>
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "done"))}
|
||
>
|
||
{t("changes.markResolved")}
|
||
</MenuItem>
|
||
</>
|
||
)}
|
||
{range === "work" && !menuFile.conflicted && (
|
||
<>
|
||
<MenuSeparator />
|
||
{(menuFile.unstaged || menuFile.untracked) && (
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => void act("stage", menuFile.path))}
|
||
>
|
||
{t("changes.stage")}
|
||
</MenuItem>
|
||
)}
|
||
{menuFile.staged && (
|
||
<MenuItem
|
||
disabled={busy}
|
||
onSelect={pick(fileCtx.close, () => void act("unstage", menuFile.path))}
|
||
>
|
||
{t("changes.unstage")}
|
||
</MenuItem>
|
||
)}
|
||
<MenuItem onSelect={pick(fileCtx.close, () => toggleNeverStage(menuFile.path))}>
|
||
{t(neverStage.has(menuFile.path) ? "changes.allowStage" : "changes.neverStage")}
|
||
</MenuItem>
|
||
<MenuItem
|
||
danger
|
||
disabled={busy}
|
||
testId="lane-changes-menu-discard"
|
||
// Two clicks here too: the first arms (the row's trash
|
||
// shows it), only the second reaches the server.
|
||
onSelect={() =>
|
||
menuArmed
|
||
? pick(fileCtx.close, () => void act("discard", menuFile.path, true))()
|
||
: setConfirmDiscard(menuFile.path)
|
||
}
|
||
>
|
||
{menuArmed ? t("changes.discardConfirm") : t("changes.discard")}
|
||
</MenuItem>
|
||
</>
|
||
)}
|
||
<MenuSeparator />
|
||
<MenuItem onSelect={pick(fileCtx.close, () => copy(menuFile.path))}>
|
||
{t("changes.copyPath")}
|
||
</MenuItem>
|
||
</ContextMenu>
|
||
)}
|
||
{treeCtx.menu && (
|
||
<ContextMenu
|
||
x={treeCtx.menu.x}
|
||
y={treeCtx.menu.y}
|
||
onClose={treeCtx.close}
|
||
testId="lane-changes-tree-menu"
|
||
>
|
||
{!treeCtx.menu.item.dir && (
|
||
<MenuItem onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}>
|
||
{t("changes.openFile", { path: treeCtx.menu.item.name })}
|
||
</MenuItem>
|
||
)}
|
||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||
{t("changes.copyPath")}
|
||
</MenuItem>
|
||
</ContextMenu>
|
||
)}
|
||
{commitCtx.menu && (
|
||
<ContextMenu
|
||
x={commitCtx.menu.x}
|
||
y={commitCtx.menu.y}
|
||
onClose={commitCtx.close}
|
||
testId="lane-changes-commit-menu"
|
||
>
|
||
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
|
||
{t("changes.viewCommit")}
|
||
</MenuItem>
|
||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
|
||
{t("changes.copySha")}
|
||
</MenuItem>
|
||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||
{t("changes.copySubject")}
|
||
</MenuItem>
|
||
</ContextMenu>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
// One change block, named by its hunk's `@@` line and its index inside that
|
||
// hunk, matched against a fresh diff at the context this diff was read
|
||
// with — so the server never applies a stale patch.
|
||
const actHunk = async (
|
||
action: "stage-hunk" | "discard-hunk",
|
||
path: string,
|
||
change: LaneDiffChange
|
||
) => {
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await api.lanes.gitAction(target, action, {
|
||
path,
|
||
hunk: change.hunk.header,
|
||
block: change.block,
|
||
context: data?.available ? data.context : undefined,
|
||
...(action === "discard-hunk" ? { confirm: true } : {}),
|
||
});
|
||
if (laneNow.current !== lane) return;
|
||
setArmedHunk(null);
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// Stage / Undo on each change block of a file with unstaged changes. The diff is
|
||
// HEAD → working tree, so a staged-only file gets none: undoing its tree
|
||
// would leave the index holding the change.
|
||
const hunkActions = (f: LaneChangeFile) =>
|
||
range === "work" && f.unstaged && !f.untracked && !f.binary
|
||
? (change: LaneDiffChange) => {
|
||
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
|
||
const armed = armedHunk === key;
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||
disabled={busy}
|
||
onClick={() =>
|
||
armed ? void actHunk("discard-hunk", f.path, change) : setArmedHunk(key)
|
||
}
|
||
className={`${HUNK_CHIP} ${
|
||
armed
|
||
? "border-status-danger bg-status-danger/20 text-status-danger"
|
||
: "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger"
|
||
}`}
|
||
>
|
||
{armed ? t("changes.undoHunkShortConfirm") : t("changes.undoHunkShort")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label={t("changes.stageHunk")}
|
||
title={t("changes.stageHunk")}
|
||
disabled={busy}
|
||
onClick={() => void actHunk("stage-hunk", f.path, change)}
|
||
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
|
||
>
|
||
{t("changes.stageHunkShort")}
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
: undefined;
|
||
|
||
const commitStaged = async () => {
|
||
const lane = laneId;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
// `only: "staged"` — the whole point of staging file by file is that the
|
||
// commit takes the index as it stands, with no `add -A` sweeping the
|
||
// rest of the tree in behind it.
|
||
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
|
||
if (laneNow.current !== lane) return;
|
||
setMessage("");
|
||
refreshGit();
|
||
await load();
|
||
} catch (err) {
|
||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setBusy(false);
|
||
}
|
||
};
|
||
|
||
// ~10 s: the server runs `claude -p` on the staged diff. Its own flag, not
|
||
// `busy`, so staging stays usable while it thinks.
|
||
const suggestMessage = async () => {
|
||
const lane = laneId;
|
||
setSuggesting(true);
|
||
try {
|
||
const { message: suggested } = await api.lanes.suggestCommitMessage(target);
|
||
if (laneNow.current === lane) setMessage(suggested);
|
||
} catch (err) {
|
||
if (laneNow.current === lane) toast.error(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
if (laneNow.current === lane) setSuggesting(false);
|
||
}
|
||
};
|
||
|
||
const rereadAll = () => {
|
||
refreshGit();
|
||
// The tree cache is dropped whole: after a switch any folder may differ.
|
||
setTreeChildren({});
|
||
setExpandedDirs(new Set());
|
||
setReread((n) => n + 1);
|
||
void load();
|
||
};
|
||
|
||
const toggleCollapsed = (path: string) =>
|
||
setCollapsed((prev) => {
|
||
const next = new Set(prev);
|
||
if (!next.delete(path)) next.add(path);
|
||
return next;
|
||
});
|
||
|
||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||
dirtyRef.current = dirty;
|
||
useUnsavedGuard(dirty);
|
||
const dirtyChangeRef = useRef(onDirtyChange);
|
||
dirtyChangeRef.current = onDirtyChange;
|
||
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
|
||
// Unmounted with an edit open (the panel closed or moved on, after the
|
||
// Workspace asked): nothing is dirty any more.
|
||
useEffect(() => () => dirtyChangeRef.current?.(false), []);
|
||
|
||
const files = data?.available ? data.files : [];
|
||
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
|
||
const stagedCount = range === "work" ? files.filter((f) => f.staged && !f.conflicted).length : 0;
|
||
// What "Stage all" sends: every listed file with a change the index lacks.
|
||
// Never a conflicted one — staging it would mark it resolved.
|
||
const toStage =
|
||
range === "work"
|
||
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
|
||
: [];
|
||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||
|
||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||
// so a later reappearance starts from the first click again.
|
||
useEffect(() => {
|
||
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
|
||
setConfirmDiscard(null);
|
||
}
|
||
}, [data, files, confirmDiscard]);
|
||
|
||
// A tree or quick-open pick opens a changed file on its Changes like any
|
||
// other, and an unchanged one — no diff to show — as the whole file, in the
|
||
// editor. With an edit open it would drop it, so a dirty draft asks first.
|
||
const openWhole = async (p: string) => {
|
||
if (!(await confirmDrop())) return;
|
||
showNow(p);
|
||
// The overview's list, or — with one file open, the list is that file —
|
||
// the working-copy facts' (capped) list of changed paths.
|
||
const changed = (git?.available ? git.files : []).map((f) => f.path);
|
||
if ([...files.map((f) => f.path), ...changed].includes(p)) return;
|
||
editWanted.current = editableFile(range, p);
|
||
setWhole(true);
|
||
};
|
||
|
||
// The Files mode's body. At full width it lives in the sidebar instead, so
|
||
// the Files button is gone there and the main area never shows it twice.
|
||
const browsePane = (
|
||
<div data-testid="lane-changes-browse">
|
||
{/* Pinned: the finder is what you reach for after scrolling a
|
||
long tree, not only from its top. */}
|
||
<div className="sticky top-0 z-10 flex items-center gap-1.5 bg-surface-1 pb-2">
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={t("changes.quickOpen")}
|
||
aria-label={t("changes.quickOpen")}
|
||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||
/>
|
||
{!hits && (
|
||
<IconButton
|
||
label={
|
||
expandedDirs.size > 0
|
||
? t("changes.collapseAllFolders")
|
||
: t("changes.expandAllFolders")
|
||
}
|
||
disabled={treeBusy}
|
||
onClick={() =>
|
||
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
|
||
}
|
||
>
|
||
{expandedDirs.size > 0 ? (
|
||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||
)}
|
||
</IconButton>
|
||
)}
|
||
</div>
|
||
{hits ? (
|
||
<ul className="space-y-0.5">
|
||
{hits.map((p) => (
|
||
<li key={p}>
|
||
<button
|
||
type="button"
|
||
onClick={() => void openWhole(p)}
|
||
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||
>
|
||
{p}
|
||
</button>
|
||
</li>
|
||
))}
|
||
{hits.length === 0 && (
|
||
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
|
||
)}
|
||
</ul>
|
||
) : (
|
||
<ul className="space-y-0.5">
|
||
<TreeNodes
|
||
path=""
|
||
depth={0}
|
||
children={treeChildren}
|
||
expanded={expandedDirs}
|
||
onToggleDir={toggleDir}
|
||
onOpenFile={(p) => void openWhole(p)}
|
||
onMenu={treeCtx.open}
|
||
active={full && mode === "file" ? openFile : null}
|
||
kept={neverStage}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div
|
||
data-testid="lane-changes"
|
||
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
|
||
>
|
||
{menus}
|
||
{/* Full width has room for an explorer: the tree stays beside the file
|
||
it opened, instead of being a mode you leave to read one. */}
|
||
{full && (
|
||
<aside
|
||
data-testid="lane-changes-sidebar"
|
||
className="flex w-64 shrink-0 flex-col overflow-y-auto border-r border-border-light pr-2"
|
||
>
|
||
{browsePane}
|
||
</aside>
|
||
)}
|
||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||
<LaneGitHeader
|
||
laneId={laneId}
|
||
session={session}
|
||
git={git}
|
||
onChanged={rereadAll}
|
||
onNewLane={onNewLane}
|
||
/>
|
||
{/* ONE wrapping row: where you are (mode) then how you are looking at it
|
||
(range, layout, context, whole file), refresh last. Each group is
|
||
its own inline box, so a narrow panel wraps BETWEEN groups — never
|
||
through one, which is what made the old single row unreadable. */}
|
||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||
<div className="contents">
|
||
{mode === "file" ? (
|
||
<button
|
||
type="button"
|
||
onClick={goBack}
|
||
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
|
||
>
|
||
<ChevronLeft className="h-3 w-3" />
|
||
{t("changes.back")}
|
||
</button>
|
||
) : (
|
||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||
{/* Files is the whole repo's tree; at full width it is the
|
||
sidebar already, so no button. */}
|
||
{(full
|
||
? (["overview", "review", "history"] as const)
|
||
: (["overview", "browse", "review", "history"] as const)
|
||
).map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
aria-pressed={mode === m}
|
||
onClick={() => setMode(m)}
|
||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||
mode === m
|
||
? "bg-accent-solid text-white"
|
||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{t(`changes.mode.${m}`)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={rereadAll}
|
||
aria-label={t("changes.refresh")}
|
||
title={t("changes.refresh")}
|
||
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
||
>
|
||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{mode !== "history" && mode !== "browse" && (
|
||
<div className="contents">
|
||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||
{(["work", "branch"] as const).map((r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
aria-pressed={range === r}
|
||
onClick={() => {
|
||
setRange(r);
|
||
setSha(null);
|
||
}}
|
||
title={t(`changes.range.${r}Hint`)}
|
||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||
range === r
|
||
? "bg-surface-4 text-fg-primary"
|
||
: "text-fg-secondary hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{t(`changes.range.${r}`)}
|
||
</button>
|
||
))}
|
||
{range === "commit" && sha && (
|
||
<span
|
||
data-testid="lane-changes-commit-chip"
|
||
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
|
||
>
|
||
{sha.slice(0, 7)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{(mode === "file" || mode === "review") && (
|
||
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
|
||
{mode === "review" && (
|
||
<IconButton
|
||
label={allCollapsed ? t("changes.expandAll") : t("changes.collapseAll")}
|
||
disabled={files.length === 0}
|
||
onClick={() =>
|
||
setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path)))
|
||
}
|
||
>
|
||
{allCollapsed ? (
|
||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||
) : (
|
||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||
)}
|
||
</IconButton>
|
||
)}
|
||
<IconButton
|
||
label={t(`changes.layout.${layout === "unified" ? "split" : "unified"}`)}
|
||
onClick={() => setLayout(layout === "unified" ? "split" : "unified")}
|
||
>
|
||
{layout === "unified" ? (
|
||
<Columns2 className="h-3.5 w-3.5" />
|
||
) : (
|
||
<Rows3 className="h-3.5 w-3.5" />
|
||
)}
|
||
</IconButton>
|
||
{mode === "file" && (
|
||
<>
|
||
{/* Context walks BOTH ways: an expanded diff that cannot
|
||
be collapsed again leaves the reader re-opening the
|
||
file to get the tight view back. */}
|
||
<IconButton
|
||
label={t("changes.collapseContext")}
|
||
disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
|
||
onClick={() =>
|
||
setContext(
|
||
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
|
||
CONTEXT_STEPS[0]
|
||
)
|
||
}
|
||
>
|
||
<FoldVertical className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
<IconButton
|
||
label={t("changes.expandContext")}
|
||
disabled={whole || draft !== null || context >= LAST_CONTEXT}
|
||
onClick={() =>
|
||
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
|
||
}
|
||
>
|
||
<UnfoldVertical className="h-3.5 w-3.5" />
|
||
</IconButton>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{mode === "file" && (
|
||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||
{([false, true] as const).map((w) => (
|
||
<button
|
||
key={String(w)}
|
||
type="button"
|
||
aria-pressed={whole === w}
|
||
onClick={() => {
|
||
if (whole === w) return;
|
||
// Both views of a working file are the same editor: the
|
||
// draft goes along, unsaved or not.
|
||
if (editable && draft !== null) return setWhole(w);
|
||
// Back to Changes drops an open edit, so unsaved text asks first.
|
||
void confirmDrop().then((ok) => {
|
||
if (!ok) return;
|
||
if (w) editWanted.current = editableFile(range, openFile ?? "", current);
|
||
else setDraft(null);
|
||
setWhole(w);
|
||
});
|
||
}}
|
||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||
whole === w
|
||
? "bg-surface-4 text-fg-primary"
|
||
: "text-fg-secondary hover:text-fg-primary"
|
||
}`}
|
||
>
|
||
{t(w ? "changes.wholeFile" : "changes.changesView")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{git?.available && git.merging && (
|
||
<div
|
||
data-testid="lane-merge-banner"
|
||
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
{git.conflicts
|
||
? t("changes.mergeBanner", { count: git.conflicts })
|
||
: t("changes.mergeReady")}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={busy || !!git.conflicts}
|
||
onClick={() => void mergeAct("merge-commit")}
|
||
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{t("changes.mergeCommit")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
// Two clicks, like discard: aborting drops every resolution made.
|
||
onClick={() =>
|
||
armedAbort ? void mergeAct("merge-abort", { confirm: true }) : setArmedAbort(true)
|
||
}
|
||
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
|
||
>
|
||
{armedAbort ? t("changes.mergeAbortConfirm") : t("changes.mergeAbort")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||
bar sits at the bottom of the visible box instead of at the bottom of
|
||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||
a list, and a list scrolls here. */}
|
||
<div
|
||
ref={scroller}
|
||
data-testid="lane-changes-body"
|
||
className={`flex min-h-0 flex-1 flex-col ${
|
||
mode === "file" ? "overflow-hidden" : "overflow-y-auto"
|
||
}`}
|
||
>
|
||
{mode === "history" ? (
|
||
<ul data-testid="lane-changes-history">
|
||
{(() => {
|
||
const list = commits ?? [];
|
||
const graph = commitGraph(list);
|
||
return list.map((c, i) => (
|
||
<li key={c.sha}>
|
||
<button
|
||
type="button"
|
||
onClick={() => viewCommit(c.sha)}
|
||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||
style={{ height: GRAPH_ROW }}
|
||
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||
>
|
||
<GraphCell row={graph.rows[i]!} width={graph.width} />
|
||
<span className="min-w-0 flex-1">
|
||
<span className="flex items-baseline gap-1.5">
|
||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||
{c.short}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
|
||
{c.subject}
|
||
</span>
|
||
</span>
|
||
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
|
||
<span className="truncate">{c.author}</span>
|
||
<span>{new Date(c.date).toLocaleString()}</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
));
|
||
})()}
|
||
{commits === null && (
|
||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||
)}
|
||
{commits && commits.length === 0 && (
|
||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||
)}
|
||
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
|
||
<li>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-history-more"
|
||
disabled={historyLoading}
|
||
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
|
||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||
>
|
||
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
|
||
</button>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
) : mode === "browse" && !full ? (
|
||
browsePane
|
||
) : !data ? (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||
) : !data.available ? (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
|
||
) : files.length === 0 && mode !== "file" ? (
|
||
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
|
||
) : mode === "overview" ? (
|
||
<>
|
||
{/* Commit and Stage all sit ABOVE the list and stay pinned while it
|
||
scrolls: on a 60-file change-set the box at the bottom was a
|
||
scroll away from every file you had just staged. */}
|
||
{(stagedCount > 0 || toStage.length > 0) && (
|
||
<div
|
||
data-testid="lane-changes-actions"
|
||
className="sticky top-0 z-10 mb-1.5 flex items-center gap-1.5 border-b border-border-light bg-surface-1 pb-2"
|
||
>
|
||
{/* ONE row: message, commit, then the two bulk index moves. */}
|
||
{stagedCount > 0 && (
|
||
<div
|
||
data-testid="lane-changes-commit"
|
||
className="flex min-w-0 flex-1 items-center gap-1.5"
|
||
>
|
||
<input
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
|
||
}}
|
||
placeholder={t("changes.commitPlaceholder")}
|
||
aria-label={t("changes.commitPlaceholder")}
|
||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-suggest-message"
|
||
disabled={suggesting}
|
||
onClick={() => void suggestMessage()}
|
||
title={t("changes.suggestMessage")}
|
||
aria-label={t("changes.suggestMessage")}
|
||
className="shrink-0 flex items-center rounded border border-border-light px-1 py-0.5 text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{suggesting ? (
|
||
<RefreshCw className="h-3 w-3 animate-spin" aria-hidden />
|
||
) : (
|
||
<Sparkles className="h-3 w-3" aria-hidden />
|
||
)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={busy || suggesting || !message.trim()}
|
||
onClick={() => void commitStaged()}
|
||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
{t("changes.commitStaged", { count: stagedCount })}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{toStage.length > 0 && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
void act(
|
||
"stage",
|
||
toStage.map((f) => f.path)
|
||
)
|
||
}
|
||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
{t("changes.stageAll", { count: toStage.length })}
|
||
</button>
|
||
)}
|
||
{stagedCount > 0 && (
|
||
<button
|
||
type="button"
|
||
data-testid="lane-changes-unstage-all"
|
||
disabled={busy}
|
||
onClick={() =>
|
||
void act(
|
||
"unstage",
|
||
files.filter((f) => f.staged).map((f) => f.path)
|
||
)
|
||
}
|
||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<Minus className="h-3 w-3" />
|
||
{t("changes.unstageAll", { count: stagedCount })}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* The work range splits like VS Code's Source Control: what the
|
||
next commit takes, then what it leaves behind. A partly staged
|
||
file sits in both, each row offering only its own index move. */}
|
||
<div data-testid="lane-changes-overview">
|
||
{(range === "work"
|
||
? [
|
||
{ key: "conflicts", rows: files.filter((f) => f.conflicted) },
|
||
{
|
||
key: "staged",
|
||
rows: files
|
||
.filter((f) => f.staged && !f.conflicted)
|
||
.map((f) => ({ ...f, unstaged: false, untracked: false })),
|
||
},
|
||
{
|
||
key: "unstaged",
|
||
rows: files
|
||
.filter((f) => (f.unstaged || f.untracked) && !f.conflicted)
|
||
.map((f) => ({ ...f, staged: false })),
|
||
},
|
||
]
|
||
: [{ key: "all", rows: files }]
|
||
).map(
|
||
(g) =>
|
||
g.rows.length > 0 && (
|
||
<div
|
||
key={g.key}
|
||
className="mb-1.5"
|
||
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
|
||
>
|
||
{g.key !== "all" && (
|
||
<button
|
||
type="button"
|
||
aria-expanded={!treeCollapsed.has(`${g.key}:`)}
|
||
onClick={() => toggleTreeDir(`${g.key}:`)}
|
||
className="flex w-full items-center gap-1.5 px-1 py-0.5 text-left text-[10px] font-semibold uppercase tracking-wide text-fg-secondary hover:text-fg-primary"
|
||
>
|
||
{treeCollapsed.has(`${g.key}:`) ? (
|
||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||
) : (
|
||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||
)}
|
||
{t(`changes.${g.key}Group`)}
|
||
<span className="rounded-full bg-surface-4 px-1.5 font-mono text-fg-primary">
|
||
{g.rows.length}
|
||
</span>
|
||
</button>
|
||
)}
|
||
{!treeCollapsed.has(`${g.key}:`) && (
|
||
<ul data-group={g.key} className="space-y-0.5">
|
||
{/* Folders like JetBrains' commit view: a long
|
||
change-set reads by area, not as one flat list. */}
|
||
<ChangeTreeNodes
|
||
dir={changeTree(g.rows)}
|
||
depth={g.key === "all" ? 0 : 1}
|
||
group={g.key}
|
||
collapsed={treeCollapsed}
|
||
onToggle={toggleTreeDir}
|
||
renderFile={(f, name, depth) => (
|
||
<ChangeRow
|
||
key={f.path}
|
||
f={f}
|
||
name={name}
|
||
depth={depth}
|
||
range={range}
|
||
busy={busy}
|
||
armed={confirmDiscard === f.path}
|
||
onOpen={show}
|
||
onAct={(...a) => void act(...a)}
|
||
onArm={setConfirmDiscard}
|
||
onResolve={resolve}
|
||
onMenu={fileCtx.open}
|
||
kept={neverStage.has(f.path)}
|
||
onKeep={toggleNeverStage}
|
||
/>
|
||
)}
|
||
folderAction={
|
||
g.key === "staged" || g.key === "unstaged"
|
||
? (rows) => (
|
||
<IconButton
|
||
label={t(
|
||
g.key === "staged"
|
||
? "changes.unstageFolder"
|
||
: "changes.stageFolder"
|
||
)}
|
||
disabled={busy}
|
||
onClick={() =>
|
||
void act(
|
||
g.key === "staged" ? "unstage" : "stage",
|
||
rows
|
||
.filter(
|
||
(f) => g.key === "staged" || !neverStage.has(f.path)
|
||
)
|
||
.map((f) => f.path)
|
||
)
|
||
}
|
||
>
|
||
{g.key === "staged" ? (
|
||
<Minus className="h-3 w-3" />
|
||
) : (
|
||
<Plus className="h-3 w-3" />
|
||
)}
|
||
</IconButton>
|
||
)
|
||
: undefined
|
||
}
|
||
/>
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</>
|
||
) : mode === "file" && range === "work" && current?.conflicted && !rawEdit && openFile ? (
|
||
<LaneConflictView
|
||
laneId={laneId}
|
||
session={session}
|
||
path={openFile}
|
||
onResolved={() => {
|
||
refreshGit();
|
||
void load();
|
||
goBack();
|
||
}}
|
||
onEdit={() => {
|
||
setRawEdit(true);
|
||
editWanted.current = true;
|
||
setWhole(true);
|
||
}}
|
||
/>
|
||
) : mode === "file" ? (
|
||
current || (whole && text) || openImage ? (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="mb-1 flex items-baseline gap-1.5">
|
||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
|
||
{openFile}
|
||
</span>
|
||
{current && (
|
||
<Delta
|
||
added={current.added}
|
||
deleted={current.deleted}
|
||
binary={current.binary}
|
||
/>
|
||
)}
|
||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-preview"
|
||
aria-pressed={mdPreview}
|
||
onClick={() => setMdPreview((v) => !v)}
|
||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||
>
|
||
{mdPreview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||
</button>
|
||
)}
|
||
{/* Only the working file is editable: a commit is history. */}
|
||
{/* A working file opens in the editor; the read-only diff
|
||
(Whole file off) keeps an Edit button back into it. */}
|
||
{range !== "commit" &&
|
||
!openImage &&
|
||
(draft === null && whole && text && !text.sha ? (
|
||
<span
|
||
data-testid="lane-file-noedit"
|
||
className="shrink-0 self-center text-[10px] text-status-warning"
|
||
>
|
||
{t(noEditReason(text))}
|
||
</span>
|
||
) : draft === null && whole ? null : draft === null ? (
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-edit"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
// The file is not loaded yet: the editor opens as
|
||
// soon as the whole-file read lands.
|
||
editWanted.current = true;
|
||
setWhole(true);
|
||
}}
|
||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<Pencil className="h-3 w-3" />
|
||
{t("changes.edit")}
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-save"
|
||
disabled={busy || !dirty}
|
||
onClick={() => void saveDraft()}
|
||
className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40"
|
||
>
|
||
{t("changes.save")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
data-testid="lane-file-cancel"
|
||
disabled={busy || !dirty}
|
||
onClick={() =>
|
||
void confirmDrop().then(
|
||
(ok) => ok && setDraft(text ? text.lines.join("\n") : null)
|
||
)
|
||
}
|
||
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||
>
|
||
{t("changes.cancelEdit")}
|
||
</button>
|
||
</>
|
||
))}
|
||
</div>
|
||
{openImage ? (
|
||
<ImageView
|
||
path={openImage}
|
||
src={api.lanes.gitRawUrl(
|
||
target,
|
||
openImage,
|
||
range === "commit" && sha ? sha : "work"
|
||
)}
|
||
/>
|
||
) : !mdPreview &&
|
||
(whole || editable) &&
|
||
(draft !== null || (editable && text === null)) ? (
|
||
// The editor, once both texts are in: not the diff first.
|
||
draft === null || (baseText === null && range !== "commit") ? (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||
) : (
|
||
<Suspense
|
||
fallback={<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>}
|
||
>
|
||
<DiffEditor
|
||
key={`${openFile}:${layout}:${whole}`}
|
||
layout={layout}
|
||
collapse={!whole}
|
||
revertLabel={t("changes.revertBlock")}
|
||
value={draft}
|
||
original={baseText ?? draft}
|
||
lang={langFor(openFile ?? "")}
|
||
label={t("changes.editorLabel", { path: openFile })}
|
||
onChange={setDraft}
|
||
onSave={() => {
|
||
if (!busy) void saveDraft();
|
||
}}
|
||
/>
|
||
</Suspense>
|
||
)
|
||
) : whole ? (
|
||
text && mdPreview ? (
|
||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||
<MarkdownPreview text={text.lines.join("\n")} />
|
||
</div>
|
||
) : text ? (
|
||
<LaneFileView
|
||
path={text.path}
|
||
lines={text.lines}
|
||
truncated={text.truncated}
|
||
fill
|
||
/>
|
||
) : (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||
)
|
||
) : current ? (
|
||
<LaneDiffView
|
||
file={current}
|
||
layout={layout}
|
||
fill
|
||
hunkActions={whole ? undefined : hunkActions(current)}
|
||
onComment={comment}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<p className="text-[11px] text-fg-muted">{t("changes.fileGone")}</p>
|
||
)
|
||
) : (
|
||
<div data-testid="lane-changes-review" className="space-y-3">
|
||
{files.map((f) => {
|
||
const shut = collapsed.has(f.path);
|
||
return (
|
||
<div
|
||
key={f.path}
|
||
data-review-file=""
|
||
className="rounded border border-border-light"
|
||
>
|
||
{/* The whole header folds the file; only the PATH opens it.
|
||
A header that opened the file on any click made folding a
|
||
target-practice exercise against one chevron. */}
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => toggleCollapsed(f.path)}
|
||
onContextMenu={(e) => fileCtx.open(e, f)}
|
||
onKeyDown={(e) => {
|
||
// Only the header's own keys: Enter on the path button
|
||
// inside bubbles here and must open, not fold.
|
||
if (e.target !== e.currentTarget) return;
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
toggleCollapsed(f.path);
|
||
}
|
||
}}
|
||
aria-expanded={!shut}
|
||
aria-label={
|
||
shut
|
||
? t("changes.expandFile", { path: f.path })
|
||
: t("changes.collapseFile", { path: f.path })
|
||
}
|
||
data-sticky-top=""
|
||
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-2 px-2 py-1 hover:bg-surface-3 ${
|
||
shut ? "" : "border-b border-border-light"
|
||
}`}
|
||
>
|
||
<ChevronRight
|
||
className={`h-3 w-3 shrink-0 self-center text-fg-muted transition-transform ${
|
||
shut ? "" : "rotate-90"
|
||
}`}
|
||
/>
|
||
<span
|
||
className={`shrink-0 font-mono text-[11px] font-semibold ${statusClass(f.status, f.untracked)}`}
|
||
>
|
||
{f.untracked ? "A" : f.status.trim()[0] || "M"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
show(f.path);
|
||
}}
|
||
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-fg-primary hover:underline"
|
||
title={t("changes.openFile", { path: f.path })}
|
||
>
|
||
{f.path}
|
||
</button>
|
||
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
|
||
{range === "work" && (
|
||
// The header folds on click; its buttons must not.
|
||
<span className="self-center" onClick={(e) => e.stopPropagation()}>
|
||
<FileActions
|
||
f={f}
|
||
busy={busy}
|
||
armed={confirmDiscard === f.path}
|
||
onAct={(...a) => void act(...a)}
|
||
onArm={setConfirmDiscard}
|
||
/>
|
||
</span>
|
||
)}
|
||
</div>
|
||
{!shut && (
|
||
<LaneDiffView
|
||
file={f}
|
||
layout={layout}
|
||
hunkActions={hunkActions(f)}
|
||
onComment={comment}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{data?.available && data.truncated && mode !== "history" && mode !== "browse" && (
|
||
<p className="mt-2 text-[10px] text-status-warning">{t("changes.truncated")}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default LaneChangesPanel;
|