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:
@@ -31,6 +31,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
|
||||
@@ -38,11 +39,31 @@ export type DiffLayout = "unified" | "split";
|
||||
|
||||
/**
|
||||
* How tall a diff box may be. `fill` (the single-file view) takes the height
|
||||
* its parent gives it; otherwise — the review list, which stacks many files —
|
||||
* each file is capped so its own horizontal scrollbar stays on screen.
|
||||
* its parent gives it. In the review list, which stacks many files, a diff
|
||||
* grows to its full height so the PANEL is the one vertical scroller — a
|
||||
* 60vh box per file nested inside it made hunk navigation scroll two boxes
|
||||
* at once and land nowhere predictable. Only a windowed diff (above
|
||||
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
|
||||
* scroller.
|
||||
*/
|
||||
function boxHeight(fill: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
|
||||
function boxHeight(fill: boolean, windowed: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `el` to the top of the scroller that actually scrolls it, below
|
||||
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
|
||||
* ancestor, the page included.
|
||||
*/
|
||||
function scrollToTop(el: HTMLElement, pad: number) {
|
||||
let s = el.parentElement;
|
||||
while (
|
||||
s &&
|
||||
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
|
||||
)
|
||||
s = s.parentElement;
|
||||
if (!s) return;
|
||||
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
|
||||
}
|
||||
|
||||
/** Rows above which a view windows; at or below it renders exactly as before. */
|
||||
@@ -284,21 +305,79 @@ function markerClass(type: LaneDiffLine["type"]) {
|
||||
return "text-fg-muted";
|
||||
}
|
||||
|
||||
/** Per-hunk controls (stage / undo) beside each `@@` line. A context rather
|
||||
* than a prop because the header renders from four places. */
|
||||
const HunkActions = createContext<((hunk: LaneDiffHunk) => ReactNode) | null>(null);
|
||||
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
|
||||
* last changed line. A context rather than a prop because lines render from
|
||||
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (hunk: LaneDiffHunk) => ReactNode;
|
||||
hunks: LaneDiffHunk[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
function HunkHeader({
|
||||
hunk,
|
||||
fixed,
|
||||
noActions,
|
||||
}: {
|
||||
hunk: LaneDiffHunk;
|
||||
fixed?: boolean;
|
||||
/** The split view's left column: one set of controls per hunk, not two. */
|
||||
noActions?: boolean;
|
||||
}) {
|
||||
const actions = useContext(HunkActions);
|
||||
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
|
||||
function lastChangedLine(hunk: LaneDiffHunk) {
|
||||
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
|
||||
if (hunk.lines[i]!.type !== "ctx") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function lastChangedRow(hunk: LaneDiffHunk) {
|
||||
const rows = analyseHunk(hunk).rows;
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const r = rows[i]!;
|
||||
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The review bar: hangs just BELOW the last changed line, pinned to the
|
||||
* visible right edge, so it sits on the change it acts on — the editor-style
|
||||
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
|
||||
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
const index = ctx.hunks.indexOf(hunk);
|
||||
const total = ctx.hunks.length;
|
||||
const nav =
|
||||
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
|
||||
return (
|
||||
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
|
||||
<span
|
||||
data-testid="lane-hunk-bar"
|
||||
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.prevHunk")}
|
||||
title={t("changes.prevHunk")}
|
||||
disabled={index <= 0}
|
||||
onClick={() => ctx.go(index - 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.nextHunk")}
|
||||
title={t("changes.nextHunk")}
|
||||
disabled={index >= total - 1}
|
||||
onClick={() => ctx.go(index + 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="px-0.5 tabular-nums text-fg-muted">
|
||||
{t("changes.hunkOf", { n: index + 1, total })}
|
||||
</span>
|
||||
{ctx.actions(hunk)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
|
||||
const ctx = useContext(HunkActions);
|
||||
const at = hunk.header.split("@@")[1];
|
||||
// Windowed, the header is one ROW_PX row like the lines around it.
|
||||
return (
|
||||
@@ -308,14 +387,12 @@ function HunkHeader({
|
||||
}`}
|
||||
style={fixed ? FIXED : undefined}
|
||||
data-diff-item={fixed ? "" : undefined}
|
||||
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
</span>
|
||||
{actions && !noActions && (
|
||||
<span className="sticky right-2 flex shrink-0 items-center gap-0.5">{actions(hunk)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -325,11 +402,14 @@ function UnifiedLine({
|
||||
lang,
|
||||
emph,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
line: LaneDiffLine;
|
||||
lang: string;
|
||||
emph?: [number, number];
|
||||
style?: CSSProperties;
|
||||
/** This is the hunk's last changed line: hang the review bar under it. */
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style}>
|
||||
@@ -341,17 +421,25 @@ function UnifiedLine({
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||||
const { emph } = analyseHunk(hunk);
|
||||
const last = lastChangedLine(hunk);
|
||||
return (
|
||||
<div>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{hunk.lines.map((line, i) => (
|
||||
<UnifiedLine key={i} line={line} lang={lang} emph={emph.get(i)} />
|
||||
<UnifiedLine
|
||||
key={i}
|
||||
line={line}
|
||||
lang={lang}
|
||||
emph={emph.get(i)}
|
||||
bar={i === last ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -384,11 +472,13 @@ function SplitLine({
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
row: PairRow;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
return (
|
||||
@@ -403,6 +493,7 @@ function SplitLine({
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -421,9 +512,16 @@ function SplitColumn({
|
||||
<>
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} noActions={side === "left"} />
|
||||
<HunkHeader hunk={hunk} />
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<SplitLine key={i} row={row} lang={lang} side={side} />
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per hunk: on the right (new) column only.
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -449,7 +547,7 @@ function SplitSlice({
|
||||
.slice(range.start, range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={range.start + k} hunk={hunk} fixed noActions={side === "left"} />
|
||||
<HunkHeader key={range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
@@ -457,6 +555,7 @@ function SplitSlice({
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -521,7 +620,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${boxHeight(fill)}`}>
|
||||
<div className={`flex ${boxHeight(fill, win.on)}`}>
|
||||
<div
|
||||
ref={left}
|
||||
data-testid="lane-diff-split-left"
|
||||
@@ -553,9 +652,10 @@ export function LaneDiffView({
|
||||
layout: DiffLayout;
|
||||
/** Take the parent's height (single-file view) instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Controls drawn on each hunk's header row. */
|
||||
/** Controls drawn in each hunk's review bar. */
|
||||
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(file.path);
|
||||
const hunks = file.hunks;
|
||||
@@ -567,6 +667,31 @@ export function LaneDiffView({
|
||||
);
|
||||
// Unified only; split windows inside its own two scrollers.
|
||||
const win = useWindow(layout === "unified" ? items.length : 0);
|
||||
const splitWindowed = useMemo(
|
||||
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
|
||||
[layout, hunks]
|
||||
);
|
||||
|
||||
// The bar's arrows: bring hunk `index`'s header into view. A windowed
|
||||
// unified diff may not have it in the DOM, so there it is found by offset.
|
||||
const go = (index: number) => {
|
||||
const el = root.current;
|
||||
if (!el || !hunks) return;
|
||||
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
|
||||
// The review list pins each file's header over its own diff.
|
||||
const sticky = el
|
||||
.closest("[data-review-file]")
|
||||
?.querySelector<HTMLElement>("[data-sticky-top]");
|
||||
const pad = sticky?.offsetHeight ?? 0;
|
||||
if (header) {
|
||||
scrollToTop(header, pad);
|
||||
return;
|
||||
}
|
||||
// Windowed: the header is not in the DOM, but its offset is known.
|
||||
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
@@ -574,13 +699,16 @@ export function LaneDiffView({
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<HunkActions.Provider value={hunkActions ?? null}>
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
@@ -599,6 +727,7 @@ export function LaneDiffView({
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={i === lastChangedLine(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -646,7 +775,7 @@ export function LaneFileView({
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
|
||||
Reference in New Issue
Block a user