feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's Active runs hides what the other panes show; stop/reset/remove kill every slot. No per-pane lane picker any more. - Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js) with the read path's guards, existing text files only, and a baseSha stale check (409 ESTALE). A file it cannot take says why. - Per-hunk review bar under each hunk's last changed line: prev/next, "n of N", Undo, Keep. Review diffs no longer nest their own scroll box (unless windowed), so navigation moves the one real scroller. - Files tree lists untracked-not-ignored files and colours entries by git status (M/U/A/D/R), folders holding changes get a dot. - Changes panel: Unstage all on the single commit/Stage-all row; sizes Narrow (30%) / Medium / Full screen, no 70/30. - Lane card: the needs-you banner is gone (the strip tile keeps it).
This commit is contained in:
@@ -37,11 +37,11 @@ import {
|
||||
FoldVertical,
|
||||
Folder,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Trash2,
|
||||
Undo2,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -99,6 +99,24 @@ function Delta({
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 } {
|
||||
if (code === "??") return { letter: "U", tone: "text-status-success" };
|
||||
if (code.includes("A")) return { letter: "A", tone: "text-status-success" };
|
||||
if (code.includes("D")) return { letter: "D", tone: "text-status-danger" };
|
||||
if (code.includes("R")) return { letter: "R", tone: "text-status-warning" };
|
||||
return { letter: "M", tone: "text-status-warning" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -123,44 +141,62 @@ function TreeNodes({
|
||||
if (!entries) return null;
|
||||
return (
|
||||
<>
|
||||
{entries.map((e) => (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
className="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{e.dir ? (
|
||||
expanded.has(e.path) ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
{entries.map((e) => {
|
||||
const mark = e.status ? treeMark(e.status) : null;
|
||||
return (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{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" />
|
||||
)
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
<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}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
{e.dir ? (
|
||||
<Folder className="h-3 w-3 shrink-0 text-accent" />
|
||||
) : (
|
||||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{e.name}</span>
|
||||
</button>
|
||||
{e.dir && expanded.has(e.path) && (
|
||||
<ul>
|
||||
<TreeNodes
|
||||
path={e.path}
|
||||
depth={depth + 1}
|
||||
children={children}
|
||||
expanded={expanded}
|
||||
onToggleDir={onToggleDir}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -194,6 +230,11 @@ function IconButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -335,9 +376,25 @@ export function LaneChangesPanel({
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<string[] | null>(null);
|
||||
const [text, setText] = useState<{ path: string; lines: string[]; truncated: boolean } | null>(
|
||||
null
|
||||
);
|
||||
const [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);
|
||||
useEffect(() => {
|
||||
if (!editWanted.current || !text) return;
|
||||
editWanted.current = false;
|
||||
if (text.sha) setDraft(text.lines.join("\n"));
|
||||
// Asked for from the diff, refused once the file arrived: say why here,
|
||||
// not with an Edit button that silently does nothing.
|
||||
else setError(t(noEditReason(text)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [text]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
|
||||
@@ -498,7 +555,17 @@ export function LaneChangesPanel({
|
||||
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
forLane(laneId, (r) =>
|
||||
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
|
||||
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)));
|
||||
@@ -518,6 +585,7 @@ export function LaneChangesPanel({
|
||||
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.
|
||||
@@ -550,9 +618,33 @@ export function LaneChangesPanel({
|
||||
setContext(CONTEXT_STEPS[0]);
|
||||
setWhole(false);
|
||||
setText(null);
|
||||
setDraft(null);
|
||||
setMode("file");
|
||||
};
|
||||
|
||||
// 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(lane, text.path, draft, text.sha);
|
||||
if (laneNow.current !== lane) return;
|
||||
if (r.available) {
|
||||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||||
}
|
||||
setDraft(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// An action that resolves after a lane switch belongs to the lane left: its
|
||||
// `load` is the pre-switch one and would take a fresh ticket and paint the
|
||||
// old lane's files here, so everything after the await is dropped.
|
||||
@@ -612,23 +704,32 @@ export function LaneChangesPanel({
|
||||
const armed = armedHunk === key;
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
label={t("changes.stageHunk")}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||||
title={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||||
disabled={busy}
|
||||
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onClick={() =>
|
||||
armed ? void actHunk("discard-hunk", f.path, hunk.header) : 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"
|
||||
}`}
|
||||
>
|
||||
<Undo2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
|
||||
</IconButton>
|
||||
{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, hunk.header)}
|
||||
className={`${HUNK_CHIP} border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35`}
|
||||
>
|
||||
{t("changes.stageHunkShort")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -833,6 +934,7 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={whole}
|
||||
disabled={draft !== null}
|
||||
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"
|
||||
@@ -974,10 +1076,14 @@ export function LaneChangesPanel({
|
||||
{(stagedCount > 0 || toStage.length > 0) && (
|
||||
<div
|
||||
data-testid="lane-changes-actions"
|
||||
className="sticky top-0 z-10 mb-1.5 space-y-1.5 border-b border-border-light bg-surface-3 pb-2"
|
||||
className="sticky top-0 z-10 mb-1.5 flex items-center gap-1.5 border-b border-border-light bg-surface-3 pb-2"
|
||||
>
|
||||
{/* ONE row: message, commit, then the two bulk index moves. */}
|
||||
{stagedCount > 0 && (
|
||||
<div data-testid="lane-changes-commit" className="flex items-center gap-1.5">
|
||||
<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)}
|
||||
@@ -992,7 +1098,7 @@ export function LaneChangesPanel({
|
||||
type="button"
|
||||
disabled={busy || !message.trim()}
|
||||
onClick={() => void commitStaged()}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
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>
|
||||
@@ -1008,12 +1114,29 @@ export function LaneChangesPanel({
|
||||
toStage.map((f) => f.path)
|
||||
)
|
||||
}
|
||||
className="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"
|
||||
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>
|
||||
)}
|
||||
<ul data-testid="lane-changes-overview" className="space-y-0.5">
|
||||
@@ -1042,8 +1165,71 @@ export function LaneChangesPanel({
|
||||
{current && (
|
||||
<Delta added={current.added} deleted={current.deleted} binary={current.binary} />
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{range !== "commit" &&
|
||||
(draft === null && whole && text && !text.sha ? (
|
||||
<span
|
||||
data-testid="lane-file-noedit"
|
||||
className="shrink-0 self-center text-[10px] text-status-warning"
|
||||
>
|
||||
{t(noEditReason(text))}
|
||||
</span>
|
||||
) : draft === null ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-edit"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
// From the diff the file is not loaded yet: the
|
||||
// editor opens as soon as the whole-file read lands.
|
||||
if (whole && text?.sha) setDraft(text.lines.join("\n"));
|
||||
else 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}
|
||||
onClick={() => void saveDraft()}
|
||||
className="shrink-0 self-center rounded border border-accent bg-accent/15 px-1.5 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:opacity-40"
|
||||
>
|
||||
{t("changes.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setDraft(null)}
|
||||
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("changes.cancelEdit")}
|
||||
</button>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{whole ? (
|
||||
{whole && draft !== null ? (
|
||||
<textarea
|
||||
data-testid="lane-file-editor"
|
||||
aria-label={t("changes.editorLabel", { path: openFile })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (!busy) void saveDraft();
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
|
||||
/>
|
||||
) : whole ? (
|
||||
text ? (
|
||||
<LaneFileView
|
||||
path={text.path}
|
||||
@@ -1071,7 +1257,11 @@ export function LaneChangesPanel({
|
||||
{files.map((f) => {
|
||||
const shut = collapsed.has(f.path);
|
||||
return (
|
||||
<div key={f.path} className="rounded border border-border-light">
|
||||
<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. */}
|
||||
@@ -1094,6 +1284,7 @@ export function LaneChangesPanel({
|
||||
? t("changes.expandFile", { path: f.path })
|
||||
: t("changes.collapseFile", { path: f.path })
|
||||
}
|
||||
data-sticky-top=""
|
||||
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
|
||||
shut ? "" : "border-b border-border-light"
|
||||
}`}
|
||||
|
||||
Reference in New Issue
Block a user