feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)

- 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
This commit is contained in:
2026-10-06 15:38:25 +07:00
parent f0500dc592
commit daf5b7be92
65 changed files with 2414 additions and 587 deletions
+297 -112
View File
@@ -7,6 +7,10 @@
* 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
@@ -45,7 +49,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
@@ -57,6 +61,7 @@ import {
Code2,
Columns2,
Eye,
EyeOff,
FileText,
FoldVertical,
Folder,
@@ -77,12 +82,19 @@ import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
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,
@@ -136,6 +148,16 @@ function Delta({
/** 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 }) {
@@ -198,10 +220,13 @@ function TreeNodes({
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;
@@ -216,17 +241,25 @@ function TreeNodes({
<>
{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 ${
mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary"
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 ? (
@@ -268,6 +301,7 @@ function TreeNodes({
onOpenFile={onOpenFile}
onMenu={onMenu}
active={active}
kept={kept}
/>
</ul>
)}
@@ -322,12 +356,17 @@ function FileActions({
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 (
@@ -350,6 +389,14 @@ function FileActions({
<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}
@@ -425,8 +472,12 @@ function ChangeRow({
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;
@@ -461,8 +512,10 @@ function ChangeRow({
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
title={f.path}
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>
@@ -475,7 +528,15 @@ function ChangeRow({
(f.conflicted ? (
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
) : (
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
<FileActions
f={f}
busy={busy}
armed={armed}
onAct={onAct}
onArm={onArm}
kept={kept}
onKeep={onKeep}
/>
))}
</li>
);
@@ -559,11 +620,56 @@ function ChangeTreeNodes({
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;
@@ -578,6 +684,8 @@ export function LaneChangesPanel({
/** 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:
@@ -607,7 +715,10 @@ export function LaneChangesPanel({
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
const [sha, setSha] = useState<string | null>(null);
const [context, setContext] = useState(CONTEXT_STEPS[0]);
const [layout, setLayout] = useState<DiffLayout>("unified");
// 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);
@@ -630,33 +741,17 @@ export function LaneChangesPanel({
// The whole-file editor's text while it is open; null = viewing.
const [draft, setDraft] = useState<string | null>(null);
const editWanted = useRef(false);
// Where the editor opens: the line clicked in the read view, and that view's
// scroll offset, so the text does not jump when the textarea replaces it.
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
const editorRef = useRef<HTMLTextAreaElement>(null);
const editing = draft !== null;
useEffect(() => {
const at = editAt.current;
const el = editorRef.current;
if (!editing || !at || !el) return;
editAt.current = null;
const offset = el.value
.split("\n")
.slice(0, at.line)
.reduce((n, l) => n + l.length + 1, 0);
el.setSelectionRange(offset, offset);
el.focus({ preventScroll: true });
el.scrollTop = at.scrollTop;
}, [editing]);
const startEdit = (line: number, scrollTop: number) => {
// 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;
editAt.current = { line, scrollTop };
setDraft(text.lines.join("\n"));
};
useEffect(() => {
if (!editWanted.current || !text) return;
editWanted.current = false;
if (text.sha) startEdit(0, 0);
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)));
@@ -692,6 +787,32 @@ export function LaneChangesPanel({
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.
@@ -846,10 +967,15 @@ export function LaneChangesPanel({
};
}, [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 || !openFile) return;
if (mode !== "file" || !(whole || editable) || !openFile) return;
void api.lanes
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
.then(
@@ -868,17 +994,22 @@ export function LaneChangesPanel({
)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]);
}, [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]);
// Going full width with a working file already open: straight to the editor.
useEffect(() => {
if (full && mode === "file" && range !== "commit") setWhole(true);
// Only on the switch itself — Whole file must still toggle afterwards.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [full]);
// A lane switch must not leave the previous lane's file open — its path
// almost certainly does not exist in the new one.
@@ -939,10 +1070,14 @@ export function LaneChangesPanel({
}
setOpenFile(path);
setContext(CONTEXT_STEPS[0]);
// Full width edits by default: a working file opens as the editor, and
// Whole file toggles back to its diff. A commit is history, so a diff.
setWhole(full && range !== "commit");
// 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");
@@ -978,7 +1113,8 @@ export function LaneChangesPanel({
if (r.available) {
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
}
setDraft(null);
// Saved, still editing: the editor stays on the text just written.
setDraft(r.available ? r.lines.join("\n") : null);
refreshGit();
await load();
} catch (err) {
@@ -1105,6 +1241,9 @@ export function LaneChangesPanel({
{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}
@@ -1303,7 +1442,9 @@ export function LaneChangesPanel({
// 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) : [];
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,
@@ -1314,11 +1455,17 @@ export function LaneChangesPanel({
}
}, [data, files, confirmDiscard]);
// A tree or quick-open pick opens the whole file — and, with an edit open,
// would drop it, so a dirty draft asks first.
// 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);
};
@@ -1384,6 +1531,7 @@ export function LaneChangesPanel({
onOpenFile={(p) => void openWhole(p)}
onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null}
kept={neverStage}
/>
</ul>
)}
@@ -1531,7 +1679,7 @@ export function LaneChangesPanel({
file to get the tight view back. */}
<IconButton
label={t("changes.collapseContext")}
disabled={whole || context <= CONTEXT_STEPS[0]}
disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
onClick={() =>
setContext(
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
@@ -1543,7 +1691,7 @@ export function LaneChangesPanel({
</IconButton>
<IconButton
label={t("changes.expandContext")}
disabled={whole || context >= LAST_CONTEXT}
disabled={whole || draft !== null || context >= LAST_CONTEXT}
onClick={() =>
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
}
@@ -1556,17 +1704,35 @@ export function LaneChangesPanel({
)}
{mode === "file" && (
<button
type="button"
aria-pressed={whole}
disabled={dirty}
onClick={() => setWhole(!whole)}
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
}`}
>
{t("changes.wholeFile")}
</button>
<div 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>
)}
@@ -1622,30 +1788,38 @@ export function LaneChangesPanel({
}`}
>
{mode === "history" ? (
<ul data-testid="lane-changes-history" className="space-y-0.5">
{(commits ?? []).map((c) => (
<li key={c.sha}>
<button
type="button"
onClick={() => viewCommit(c.sha)}
onContextMenu={(e) => commitCtx.open(e, c)}
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span className="flex items-baseline gap-1.5">
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
{c.short}
<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>
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
{c.subject}
</span>
</span>
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
<span className="truncate">{c.author}</span>
<span>{new Date(c.date).toLocaleString()}</span>
</span>
</button>
</li>
))}
</button>
</li>
));
})()}
{commits === null && (
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
)}
@@ -1831,6 +2005,8 @@ export function LaneChangesPanel({
onArm={setConfirmDiscard}
onResolve={resolve}
onMenu={fileCtx.open}
kept={neverStage.has(f.path)}
onKeep={toggleNeverStage}
/>
)}
folderAction={
@@ -1846,7 +2022,11 @@ export function LaneChangesPanel({
onClick={() =>
void act(
g.key === "staged" ? "unstage" : "stage",
rows.map((f) => f.path)
rows
.filter(
(f) => g.key === "staged" || !neverStage.has(f.path)
)
.map((f) => f.path)
)
}
>
@@ -1898,7 +2078,7 @@ export function LaneChangesPanel({
binary={current.binary}
/>
)}
{whole && text && draft === null && isMarkdown(openFile) && (
{whole && text && !dirty && isMarkdown(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
@@ -1911,9 +2091,8 @@ export function LaneChangesPanel({
</button>
)}
{/* Only the working file is editable: a commit is history. */}
{/* The whole file enters the editor by a click on its text;
only the diff, which has no whole text to click, keeps
an Edit button. */}
{/* 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 ? (
@@ -1923,14 +2102,7 @@ export function LaneChangesPanel({
>
{t(noEditReason(text))}
</span>
) : draft === null && whole ? (
text &&
!mdPreview && (
<span className="shrink-0 self-center text-[10px] text-fg-muted">
{t("changes.clickToEdit")}
</span>
)
) : draft === null ? (
) : draft === null && whole ? null : draft === null ? (
<button
type="button"
data-testid="lane-file-edit"
@@ -1959,8 +2131,13 @@ export function LaneChangesPanel({
</button>
<button
type="button"
disabled={busy}
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
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")}
@@ -1977,23 +2154,32 @@ export function LaneChangesPanel({
range === "commit" && sha ? sha : "work"
)}
/>
) : whole && draft !== null ? (
<textarea
ref={editorRef}
data-testid="lane-file-editor"
aria-label={t("changes.editorLabel", { path: openFile })}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (!busy) void saveDraft();
}
}}
spellCheck={false}
wrap="off"
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
/>
) : !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">
@@ -2005,7 +2191,6 @@ export function LaneChangesPanel({
lines={text.lines}
truncated={text.truncated}
fill
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
/>
) : (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>