fix(lanes,git): harden the lane git surface and fix client races in the Changes panel

Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js):
- Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a
  file name is a file name, never a glob, and odd names parse correctly.
- A lane that is a subdirectory of its repo sees and acts on ONLY its own
  paths (reads, stage/unstage/discard/commit, and gitFacts.files).
- Any file named settings.local.json (the lane's router API key) is refused as
  403 ESECRET on disk (symlinks resolved) and at every committed rev.
- commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is
  staged (checked with --no-renames, so a move into the lane is caught).
- push resolves its remote (upstream → only remote → origin), else 400
  ENOREMOTE; unstaging a rename unstages both halves.
- Git writes serialize on the repo's git dir, not the lane id, so two lanes on
  one index cannot collide on index.lock.
- git refusing an action (a numeric exit status) answers 409 EGIT with git's
  stderr instead of a 500 carrying a numeric code; one shared parseNumstat.

Client:
- Changes panel: no stale answer from a previous lane or an older quick-open
  query repaints the panel; discard disarms on lane switch; Enter on a file
  path opens it instead of folding; bounded tree expand-all.
- Large diffs (> 500 rows) render only the visible rows, with stable width
  and in-sync split columns; mirrored split scroll is clamped.
- Git bar: no "in sync" without counts, a clear no-remote message, branch list
  re-read after a switch, and no previous-lane facts after a lane switch.
- Tooltips no longer flicker or close on unrelated scrolls.
This commit is contained in:
2026-09-24 09:54:51 +07:00
parent 6153d50fa8
commit 7bc4594b94
24 changed files with 2832 additions and 342 deletions
+89 -9
View File
@@ -9,10 +9,13 @@
* touching every one of those call sites to get a designed bubble, and the
* next `title=` someone writes would be back to the browser's grey box.
* {@link GlobalTooltip} instead listens once at the document: on hover or
* keyboard focus it takes the element's `title`, suppresses the native tooltip
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
* by removing the attribute for as long as the bubble is up, and restores it
* on the way out. Nothing else in the app has to know it exists, and a control
* keeps its native tooltip (and its accessible name) when JS has not run.
* While the title is parked, a control with no other name (icon-only) carries
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
* bubble goes away with its control if that is removed from the page.
*
* ## The look
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
@@ -39,8 +42,10 @@ const SHOW_DELAY_MS = 350;
/** Gap between the control and the bubble, and the minimum viewport margin. */
const GAP = 8;
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */
const SELECTOR = "[title]:not([data-tip-off])";
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
* `[data-tip]` matches the control whose title is parked, so moving between
* its own children still resolves to it instead of re-opening the bubble. */
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
type Anchored = { text: string; rect: DOMRect };
type Placed = { left: number; top: number; arrow: number; above: boolean };
@@ -58,16 +63,29 @@ export function GlobalTooltip() {
// that unmounts it, Escape).
const parked = useRef<HTMLElement | null>(null);
const timer = useRef<number | undefined>(undefined);
// The element whose delay is running, so re-entering it does not restart it.
const pending = useRef<HTMLElement | null>(null);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
pending.current = null;
const el = parked.current;
if (el) {
// Drop the instance shim `open` installed; the prototype method is back.
delete (el as { removeAttribute?: unknown }).removeAttribute;
const text = el.dataset.tip;
if (text !== undefined) {
el.setAttribute("title", text);
// React re-set `title` while it was parked (a poll flipped the hint):
// that value is current, the parked copy is stale. React never
// re-applies an unchanged prop, so writing the old one back would win
// for good.
if (!el.hasAttribute("title")) el.setAttribute("title", text);
delete el.dataset.tip;
}
if (el.dataset.tipAria !== undefined) {
el.removeAttribute("aria-label");
delete el.dataset.tipAria;
}
parked.current = null;
}
setTip(null);
@@ -79,7 +97,9 @@ export function GlobalTooltip() {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
pending.current = el;
timer.current = window.setTimeout(() => {
pending.current = null;
// Re-read at fire time: the control may have been re-rendered, removed
// or had its title changed while the delay was running.
if (!el.isConnected) return;
@@ -87,6 +107,29 @@ export function GlobalTooltip() {
if (!current) return;
el.dataset.tip = current;
el.removeAttribute("title");
// React removing `title` while it is parked calls removeAttribute on an
// attribute that is already gone: no MutationRecord, nothing to see —
// and `restore` would write the stale text back for good. So, on THIS
// element and only while parked, that call is taken as React's intent:
// the parked copy is dropped and the bubble hides. (React DOM calls
// `node.removeAttribute`, the instance method; `restore` deletes the
// shim.) No global prototype is touched.
el.removeAttribute = (name: string) => {
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
delete el.dataset.tip;
restore();
}
Element.prototype.removeAttribute.call(el, name);
};
// The title was an icon-only control's ONLY name; keep it named.
if (
!el.hasAttribute("aria-label") &&
!el.hasAttribute("aria-labelledby") &&
!el.textContent?.trim()
) {
el.setAttribute("aria-label", current);
el.dataset.tipAria = "";
}
parked.current = el;
setTip({ text: current, rect: el.getBoundingClientRect() });
}, delay);
@@ -101,7 +144,10 @@ export function GlobalTooltip() {
};
const onOver = (e: PointerEvent) => {
const el = target(e);
if (el) open(el, SHOW_DELAY_MS);
// Already showing or already counting down for this control: a pointer
// crossing its children must neither hide nor restart it.
if (!el || el === parked.current || el === pending.current) return;
open(el, SHOW_DELAY_MS);
};
const onOut = (e: PointerEvent) => {
// Moving between children of the same control must not flicker.
@@ -112,9 +158,14 @@ export function GlobalTooltip() {
};
// Keyboard users get the same tooltip, immediately — a focus ring that
// reveals nothing is the whole reason icon-only buttons are hard to learn.
// Only KEYBOARD focus, though: a click focuses too, and without the
// `:focus-visible` check it reopened the bubble the pointerdown just
// closed, with no delay. Hover keeps its own path.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (el) open(el, 0);
if (!el || el === parked.current) return;
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
open(el, 0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") restore();
@@ -126,9 +177,17 @@ export function GlobalTooltip() {
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", restore);
document.addEventListener("keydown", onKey);
// A bubble anchored to a rect is wrong the moment anything moves.
window.addEventListener("scroll", restore, true);
// A bubble anchored to a rect is wrong the moment its anchor moves — but
// only a scroll of the document or of a box containing the anchor moves
// it. A log tailing in another panel must not close the bubble.
const onScroll = (e: Event) => {
const anchor = parked.current || pending.current;
const t = e.target;
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", restore);
window.addEventListener("blur", restore);
return () => {
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
@@ -136,12 +195,33 @@ export function GlobalTooltip() {
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", restore);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", restore, true);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", restore);
window.removeEventListener("blur", restore);
restore();
};
}, [open, restore]);
// A control unmounted under a still cursor fires no pointerout: without this
// its bubble stays up over whatever replaced it. The same goes for a title
// re-rendered under it — the bubble would keep saying the old text, so it
// hides and `restore` keeps the new attribute.
useEffect(() => {
if (!tip) return;
const watch = new MutationObserver((records) => {
const el = parked.current;
if (!el) return;
if (
!el.isConnected ||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
)
restore();
});
watch.observe(document.body, { childList: true, subtree: true });
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
return () => watch.disconnect();
}, [tip, restore]);
// Placed after the first paint, when the bubble's real size is known —
// measuring it is the only way to flip and clamp without guessing.
useEffect(() => {
@@ -10,6 +10,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
@@ -24,6 +25,24 @@ function setup(extra?: React.ReactNode) {
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
// jsdom's :focus-visible is a heuristic over the previous events and leaks
// between tests, so the tests say which kind of focus they mean: a spy answers
// `:focus-visible` from this flag and passes every other selector through.
let keyboardFocus = false;
const realMatches = Element.prototype.matches;
beforeEach(() => {
keyboardFocus = false;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
return sel === ":focus-visible"
? keyboardFocus && document.activeElement === this
: realMatches.call(this, sel);
});
});
afterEach(() => vi.restoreAllMocks());
const tabTo = (el: HTMLElement) => {
keyboardFocus = true;
act(() => el.focus());
};
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
@@ -63,7 +82,7 @@ describe("GlobalTooltip", () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
fireEvent.focusIn(button);
tabTo(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
@@ -105,4 +124,165 @@ describe("GlobalTooltip", () => {
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
it("keeps an icon-only control's accessible name while its title is parked", async () => {
setup(
<button title="Stage this file">
<svg aria-hidden="true" />
</button>
);
const icon = screen.getByRole("button", { name: "Stage this file" });
tabTo(icon);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
expect(icon).not.toHaveAttribute("title");
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
fireEvent.keyDown(document, { key: "Escape" });
// Put back exactly as found: no aria-label left behind.
expect(icon).toHaveAttribute("title", "Stage this file");
expect(icon).not.toHaveAttribute("aria-label");
});
it("does not flicker while the pointer crosses the control's own children", async () => {
setup(
<button title="Two parts">
<span data-testid="part-a">a</span>
<span data-testid="part-b">b</span>
</button>
);
const a = screen.getByTestId("part-a");
const b = screen.getByTestId("part-b");
hover(a);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
// Still up, with no fresh delay.
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
});
it("hides when its control is removed from the page", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Going away">x</button>
</>
);
hover(screen.getByRole("button", { name: "x" }));
await settle();
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
rerender(
<>
<GlobalTooltip />
</>
);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// A poll flips the hint while the pointer rests on the control.
rerender(
<>
<GlobalTooltip />
<button title="Up to date">pull</button>
</>
);
await settle();
// The bubble must not keep advertising the old text.
expect(screen.queryByText("Pull 2 commits")).toBeNull();
unhover(button);
expect(button).toHaveAttribute("title", "Up to date");
});
it("leaves no title behind when React removed it while the bubble was up", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// React "removes" an attribute that is already parked: no MutationRecord.
rerender(
<>
<GlobalTooltip />
<button>pull</button>
</>
);
await settle();
unhover(button);
expect(button).not.toHaveAttribute("title");
expect(button).not.toHaveAttribute("data-tip");
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setup(<button title="Second control">second</button>);
// A click is pointer focus: :focus-visible does not match.
const button = screen.getByRole("button", { name: "refresh" });
await user.click(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.queryByTestId("global-tooltip")).toBeNull();
tabTo(screen.getByRole("button", { name: "second" }));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
});
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
render(
<>
<GlobalTooltip />
<div data-testid="scroller">
<button title="Inside the scroller">inside</button>
</div>
<div data-testid="elsewhere" />
</>
);
const button = screen.getByRole("button", { name: "inside" });
hover(button);
await settle();
fireEvent.scroll(screen.getByTestId("elsewhere"));
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
fireEvent.scroll(screen.getByTestId("scroller"));
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Inside the scroller");
});
it("hides when the document itself scrolls", async () => {
setup();
hover(screen.getByRole("button", { name: "refresh" }));
await settle();
fireEvent.scroll(document);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
});
+4 -18
View File
@@ -27,18 +27,6 @@ import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only.
* Nothing is sent from it: an empty branch field means "let the server name
* it", which is how `LANE_BRANCH_PREFIX` keeps working. */
function slugPreview(title: string) {
return title
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
* lane record, or `null` on timeout. */
@@ -589,12 +577,10 @@ export function AddLaneModal({
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// The placeholder is what the SERVER will name the branch
// if this is left empty, so the default is visible without
// the form having to duplicate LANE_BRANCH_PREFIX.
placeholder={
title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder")
}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
+8 -1
View File
@@ -55,6 +55,13 @@ function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
const [nonce, setNonce] = useState(0);
// A lane switch drops the previous lane's facts during render, so nothing
// (the git bar's "current branch" included) shows them until the new read.
const [factsLane, setFactsLane] = useState(laneId);
if (factsLane !== laneId) {
setFactsLane(laneId);
setFacts(null);
}
useEffect(() => {
let alive = true;
@@ -854,7 +861,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
+212 -92
View File
@@ -59,6 +59,8 @@ const OVERVIEW_REFRESH_MS = 15_000;
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
/** Tree reads "expand all" keeps in flight at once. */
const TREE_CONCURRENCY = 4;
type Mode = "overview" | "review" | "file" | "history" | "browse";
@@ -191,6 +193,94 @@ function IconButton({
);
}
/** 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,
}: {
f: LaneChangeFile;
range: LaneChangeRange;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
onOpen: (path: string) => void;
onAct: (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<li className="group flex items-baseline gap-1">
<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] text-fg-primary"
title={f.path}
>
{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" && (
<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}
<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>
)}
</li>
);
}
export function LaneChangesPanel({ laneId }: { laneId: number }) {
const { t } = useTranslation(["lanes"]);
const [range, setRange] = useState<LaneChangeRange>("work");
@@ -227,7 +317,21 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
const [message, setMessage] = useState("");
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(laneId, {
@@ -236,12 +340,30 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
...(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 {
setLoading(false);
if (seq === loadSeq.current) setLoading(false);
}
}, [laneId, range, sha, mode, openFile, context]);
@@ -260,7 +382,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.then((r) => setCommits(r.commits))
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId]);
@@ -268,7 +390,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
void api.lanes
.gitTree(laneId, "")
.then((r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` is the cache this fills — reading it as a dep would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -284,36 +406,56 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.then((r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries })))
.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);
// Which lane `treeChildren` was filled for; the reset effect moves it.
const treeLane = useRef(laneId);
// Fetches whatever level isn't cached yet, depth-first, so "expand all"
// works even on a tree nothing has been opened in.
const expandAllDirs = async () => {
// A walk is many reads long; a lane switch mid-walk must not land lane A's
// tree in lane B, nor start from a cache the switch has not cleared yet.
const lane = laneId;
setTreeBusy(true);
const cache = { ...treeChildren };
const cache = treeLane.current === lane ? { ...treeChildren } : {};
const toExpand = new Set(expandedDirs);
const walk = async (path: string) => {
// Level by level, at most TREE_CONCURRENCY reads in flight: one request
// per folder all at once is thousands on a monorepo.
const visit = async (path: string, next: string[]) => {
let entries = cache[path];
if (!entries) {
entries = (await api.lanes.gitTree(laneId, path)).entries;
entries = (await api.lanes.gitTree(lane, path)).entries;
cache[path] = entries;
}
const dirs = entries.filter((e) => e.dir);
for (const d of dirs) toExpand.add(d.path);
await Promise.all(dirs.map((d) => walk(d.path)));
for (const e of entries) {
if (!e.dir) continue;
toExpand.add(e.path);
next.push(e.path);
}
};
try {
await walk("");
for (let level = [""]; level.length && laneNow.current === lane; ) {
const next: string[] = [];
// Workers share one iterator, so each path is taken exactly once.
const it = level.values();
await Promise.all(
Array.from({ length: TREE_CONCURRENCY }, async () => {
for (const path of it) await visit(path, next);
})
);
level = next;
}
if (laneNow.current !== lane) return;
setTreeChildren(cache);
setExpandedDirs(toExpand);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setTreeBusy(false);
if (laneNow.current === lane) setTreeBusy(false);
}
};
@@ -323,13 +465,19 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
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(laneId, q)
.then((r) => setHits(r.paths))
.catch(() => setHits([]));
.then((r) => live && setHits(r.paths))
.catch(() => live && setHits([]));
}, 150);
return () => window.clearTimeout(timer);
return () => {
live = false;
window.clearTimeout(timer);
};
}, [mode, laneId, query]);
// The whole-file read is a different endpoint from the diff, so it is its
@@ -338,8 +486,10 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (mode !== "file" || !whole || !openFile) return;
void api.lanes
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.then((r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
.then(
forLane(laneId, (r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]);
@@ -352,9 +502,19 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setSha(null);
setRange("work");
setTreeChildren({});
treeLane.current = laneId;
setExpandedDirs(new Set());
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(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]);
@@ -384,34 +544,41 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setMode("file");
};
// 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, confirm?: boolean) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(laneId, action, { paths: [path], ...(confirm ? { confirm } : {}) });
await api.lanes.gitAction(lane, action, { paths: [path], ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
if (laneNow.current === lane) setBusy(false);
}
};
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(laneId, "commit", { message: message.trim(), only: "staged" });
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
if (laneNow.current !== lane) return;
setMessage("");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
if (laneNow.current === lane) setBusy(false);
}
};
@@ -427,72 +594,13 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
const Row = ({ f }: { f: LaneChangeFile }) => (
<li className="group flex items-baseline gap-1">
<button
type="button"
onClick={() => show(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] text-fg-primary"
title={f.path}
>
{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" && (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => void act("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => void act("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={confirmDiscard === f.path ? 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 (confirmDiscard === f.path) void act("discard", f.path, true);
else setConfirmDiscard(f.path);
}}
>
<Trash2
className={`h-3 w-3 ${confirmDiscard === f.path ? "text-status-danger" : ""}`}
/>
</IconButton>
</span>
)}
</li>
);
// 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]);
return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
@@ -770,13 +878,22 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
<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 ? (
) : files.length === 0 && mode !== "file" ? (
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
) : mode === "overview" ? (
<>
<ul data-testid="lane-changes-overview" className="space-y-0.5">
{files.map((f) => (
<Row key={f.path} f={f} />
<ChangeRow
key={f.path}
f={f}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
))}
</ul>
{stagedCount > 0 && (
@@ -849,6 +966,9 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
tabIndex={0}
onClick={() => toggleCollapsed(f.path)}
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);
+378 -69
View File
@@ -13,10 +13,15 @@
* - Line pairing for split view happens here, on the rows the server already
* numbered. The server stays the single source of what changed; this file
* only decides how it looks.
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useRef } from "react";
import { useMemo, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
@@ -32,6 +37,100 @@ function boxHeight(fill: boolean) {
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
}
/** Rows above which a view windows; at or below it renders exactly as before. */
export const WINDOW_THRESHOLD = 500;
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
export const ROW_PX = 16;
const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX };
/**
* The `[start, end)` item range to render for a scroll position. `scrollTop`
* is clamped to the content first, so a stale offset (content that shrank, a
* layout switch) still lands on real rows instead of an empty window.
*/
export function visibleRange(
scrollTop: number,
viewport: number,
rowHeight: number,
count: number,
overscan: number
) {
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
return { start: Math.min(start, end), end };
}
/**
* Windowing state for one scroller of `count` items. Off (the whole range)
* at or below the threshold.
*/
function useWindow(count: number) {
const [top, setTop] = useState(0);
const on = count > WINDOW_THRESHOLD;
// The offset is tracked even while windowing is off, so a diff that GROWS
// past the threshold (expanded context) windows where the reader is, not at
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
const lastTop = useRef(0);
// count 0 = no scroller here (split layout): the browser drops its offset,
// so coming back to unified must start from 0, not the pre-split row.
if (count === 0) lastTop.current = 0;
const [wasOn, setWasOn] = useState(on);
if (wasOn !== on) {
setWasOn(on);
setTop(on ? lastTop.current : 0);
}
// ponytail: the viewport is taken as the window height — never smaller than
// a box that lives inside the window, so it only over-renders a few dozen
// rows, and it needs no ResizeObserver to follow a panel resize.
const range = on
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
: { start: 0, end: count };
const onScroll = (el: HTMLElement) => {
lastTop.current = el.scrollTop;
if (on) setTop(el.scrollTop);
};
return { on, range, onScroll };
}
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
function longest(texts: Iterable<string>) {
let max = 0;
for (const t of texts) {
let n = t.length;
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
if (n > max) max = n;
}
return max;
}
/** Stands in for `rows` off-screen rows. */
function Spacer({ rows }: { rows: number }) {
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
}
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
function Windowed({
count,
range,
minWidth,
children,
}: {
count: number;
range: { start: number; end: number };
minWidth: string;
children: React.ReactNode;
}) {
return (
<div data-diff-content style={{ minWidth }}>
<Spacer rows={range.start} />
{children}
<Spacer rows={count - range.end} />
</div>
);
}
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) {
const ext = filePath.split(".").pop() || "";
@@ -86,26 +185,51 @@ function emphasis(
};
}
/** Pair each run of removed lines with the run of added lines following it. */
function pairs(
lines: LaneDiffLine[]
): Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> {
const out: Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> = [];
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
type EmphPair = { del: [number, number]; add: [number, number] };
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
* index in `hunk.lines` (what the unified view renders by). */
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
// columns and every re-render of the same data share one pass. A refetch
// brings new objects and the old entries go with them.
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
/** Pair each run of removed lines with the run of added lines following it,
* carrying indices so emphasis lands without searching the hunk again. */
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
const hit = analysed.get(hunk);
if (hit) return hit;
const lines = hunk.lines;
const rows: PairRow[] = [];
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
out.push({ left: line, right: line });
rows.push({ left: line, right: line, emph: null });
i += 1;
continue;
}
const dels: LaneDiffLine[] = [];
const adds: LaneDiffLine[] = [];
while (i < lines.length && lines[i]!.type === "del") dels.push(lines[i++]!);
while (i < lines.length && lines[i]!.type === "add") adds.push(lines[i++]!);
for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) {
out.push({ left: dels[k] ?? null, right: adds[k] ?? null });
const delStart = i;
while (i < lines.length && lines[i]!.type === "del") i += 1;
const addStart = i;
while (i < lines.length && lines[i]!.type === "add") i += 1;
const dels = addStart - delStart;
const adds = i - addStart;
for (let k = 0; k < Math.max(dels, adds); k += 1) {
const left = k < dels ? lines[delStart + k]! : null;
const right = k < adds ? lines[addStart + k]! : null;
const e = left && right ? emphasis(left.text, right.text) : null;
if (e) {
emph.set(delStart + k, e.del);
emph.set(addStart + k, e.add);
}
rows.push({ left, right, emph: e });
}
}
const out = { rows, emph };
analysed.set(hunk, out);
return out;
}
@@ -115,9 +239,26 @@ function Gutter({ n }: { n: number | null }) {
);
}
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. */
function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
* `style` pins a windowed row's height; unwindowed rows carry none. */
function Row({
tint,
style,
children,
}: {
tint: string;
style?: CSSProperties;
children: React.ReactNode;
}) {
return (
<div
className={`flex w-max min-w-full ${tint}`}
style={style}
data-diff-item={style ? "" : undefined}
>
{children}
</div>
);
}
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
@@ -135,44 +276,110 @@ function markerClass(type: LaneDiffLine["type"]) {
return "text-fg-muted";
}
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
<div
className={`bg-surface-2 px-2 text-[10px] text-fg-muted ${
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
>
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</div>
);
}
function UnifiedLine({
line,
lang,
emph,
style,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
}) {
return (
<Row tint={rowTint(line.type)} style={style}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const emph = new Map<number, [number, number]>();
for (const { left, right } of pairs(hunk.lines)) {
if (!left || !right || left.type !== "del" || right.type !== "add") continue;
const e = emphasis(left.text, right.text);
if (!e) continue;
emph.set(hunk.lines.indexOf(left), e.del);
emph.set(hunk.lines.indexOf(right), e.add);
}
const { emph } = analyseHunk(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<Row key={i} tint={rowTint(line.type)}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph.get(i)} />
</span>
</Row>
<UnifiedLine key={i} line={line} lang={lang} emph={emph.get(i)} />
))}
</div>
);
}
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
* unified, a paired row in split. */
type Item = { hunk: LaneDiffHunk; i: number };
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
const out: Item[] = [];
for (const hunk of hunks) {
out.push({ hunk, i: -1 });
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
}
return out;
}
function longestInHunks(hunks: LaneDiffHunk[]) {
return longest(
(function* () {
for (const h of hunks) for (const l of h.lines) yield l.text;
})()
);
}
function SplitLine({
row,
lang,
side,
style,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
}) {
const line = side === "left" ? row.left : row.right;
return (
<Row tint={rowTint(line?.type ?? null)} style={style}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code
text={line.text}
lang={lang}
emph={side === "left" ? row.emph?.del : row.emph?.add}
/>
) : null}
</span>
</Row>
);
}
/** One side of the split view: a hunk header, then that side's rows. */
function SplitColumn({
hunks,
@@ -188,42 +395,103 @@ function SplitColumn({
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} />
{pairs(hunk.lines).map(({ left, right }, i) => {
const e =
left && right && left.type === "del" && right.type === "add"
? emphasis(left.text, right.text)
: null;
const line = side === "left" ? left : right;
return (
<Row key={i} tint={rowTint(line?.type ?? null)}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code text={line.text} lang={lang} emph={side === "left" ? e?.del : e?.add} />
) : null}
</span>
</Row>
);
})}
{analyseHunk(hunk).rows.map((row, i) => (
<SplitLine key={i} row={row} lang={lang} side={side} />
))}
</div>
))}
</>
);
}
/** Both sides, each scrolling horizontally on its own but kept in step. */
/** A windowed split column: the shared range of items, one side of each. */
function SplitSlice({
items,
range,
lang,
side,
}: {
items: Item[];
range: { start: number; end: number };
lang: string;
side: "left" | "right";
}) {
return (
<>
{items
.slice(range.start, range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed />
) : (
<SplitLine
key={range.start + k}
row={analyseHunk(hunk).rows[i]!}
lang={lang}
side={side}
style={FIXED}
/>
)
)}
</>
);
}
/** `v` held inside a scroller's own extent. */
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
return Math.min(v, Math.max(0, scrollSize - clientSize));
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
];
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
function follow(src: HTMLElement, dst: HTMLElement) {
for (const [pos, size, client] of AXES) {
// `src` pinned at its own end while `dst` sits past it: `src` is the
// narrower column echoing a clamp we just caused. Pulling `dst` back to
// it would snap the wide column home every time it scrolls past the
// narrow one's longest line.
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
const target = clampScroll(src[pos], dst[size], dst[client]);
if (dst[pos] !== target) dst[pos] = target;
}
}
/** Both sides, each its own scroller but kept in step on both axes. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
const win = useWindow(items.length);
// Mirroring fires the other pane's own scroll handler, which mirrors back —
// the equality check is what ends that, so no re-entry flag is needed.
const sync = useCallback((from: "left" | "right") => {
// the equality check (and `follow`'s edge rule) is what ends that, so no
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
// vertical drift puts a deletion beside some unrelated line. Both columns
// window off the same offset, so they always hold the same row range.
const sync = (from: "left" | "right") => {
const src = from === "left" ? left.current : right.current;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst || dst.scrollLeft === src.scrollLeft) return;
dst.scrollLeft = src.scrollLeft;
}, []);
if (!src || !dst) return;
follow(src, dst);
win.onScroll(src);
};
const column = (side: "left" | "right") =>
win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
</Windowed>
) : (
<SplitColumn hunks={hunks} lang={lang} side={side} />
);
return (
<div className={`flex ${boxHeight(fill)}`}>
@@ -233,7 +501,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
onScroll={() => sync("left")}
className="w-1/2 min-w-0 overflow-auto"
>
<SplitColumn hunks={hunks} lang={lang} side="left" />
{column("left")}
</div>
<div
ref={right}
@@ -241,7 +509,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
onScroll={() => sync("right")}
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
>
<SplitColumn hunks={hunks} lang={lang} side="right" />
{column("right")}
</div>
</div>
);
@@ -260,6 +528,15 @@ export function LaneDiffView({
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
const hunks = file.hunks;
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
const minWidth = useMemo(
// two gutters + the row's px-2, plus the marker column
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
[hunks]
);
// Unified only; split windows inside its own two scrollers.
const win = useWindow(layout === "unified" ? items.length : 0);
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -270,12 +547,31 @@ export function LaneDiffView({
<div
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" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
{items
.slice(win.range.start, win.range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
) : (
<UnifiedLine
key={win.range.start + k}
line={hunk.lines[i]!}
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
/>
)
)}
</Windowed>
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
@@ -303,19 +599,32 @@ export function LaneFileView({
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
const win = useWindow(lines.length);
// gutter + the row's px-2
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
);
return (
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
>
{lines.map((text, i) => (
<Row key={i} tint="">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
))}
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
{lines
.slice(win.range.start, win.range.end)
.map((text, k) => row(text, win.range.start + k, FIXED))}
</Windowed>
) : (
lines.map((text, i) => row(text, i))
)}
{truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
+73 -16
View File
@@ -15,7 +15,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
@@ -86,6 +86,29 @@ export function LaneGitBar({
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
// LaneCard is not keyed by lane, so this bar outlives a lane switch. All of
// the state above describes ONE lane — a message typed for lane A, Entered
// after switching, would commit B's whole tree under it — so it is reset
// during render the moment the id changes, before anything can act on it.
const [shownLane, setShownLane] = useState(laneId);
// One action at a time, checked synchronously: `busy` is state, and a second
// Enter can land before the render that disables anything.
const inFlight = useRef(false);
if (shownLane !== laneId) {
setShownLane(laneId);
setBusy(null);
setError(null);
setCommitting(false);
setMessage("");
setSwitching(false);
setBranches(null);
setWorktrees([]);
setPicked("");
setNewBranch("");
inFlight.current = false;
}
const laneNow = useRef(laneId);
laneNow.current = laneId;
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
@@ -94,17 +117,27 @@ export function LaneGitBar({
action: Action,
body?: { message?: string; only?: "staged"; branch?: string }
) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
setBusy(action);
setError(null);
try {
await api.lanes.gitAction(laneId, action, body || {});
setCommitting(false);
setMessage("");
await api.lanes.gitAction(lane, action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
// Only a landed commit consumes the message; a fetch or pull mid-typing
// must leave it where it was.
if (action === "commit") {
setCommitting(false);
setMessage("");
}
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "switch") setSwitching(false);
else if (action.startsWith("branch-")) {
setNewBranch("");
if (action === "switch" || action.startsWith("branch-")) {
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
setBranches(null);
await refreshBranches();
@@ -113,19 +146,32 @@ export function LaneGitBar({
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
setError(err instanceof Error ? err.message : String(err));
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
if (laneNow.current === lane)
setError(
(err as { code?: string })?.code === "ENOREMOTE"
? t("gitBar.errNoRemote")
: err instanceof Error
? err.message
: String(err)
);
} finally {
setBusy(null);
if (laneNow.current === lane) {
inFlight.current = false;
setBusy(null);
}
}
};
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
} catch {
setBranches([]);
if (laneNow.current === laneId) setBranches([]);
}
};
@@ -145,7 +191,10 @@ export function LaneGitBar({
<div className="flex flex-wrap items-center gap-1.5">
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{git.upstream ? (
{/* Null counts with an upstream named means git could not compare
the two (the upstream ref is gone): claiming "in sync" there
would be a lie, so it reads as not pushed. */}
{git.upstream && git.ahead != null && git.behind != null ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
@@ -223,7 +272,7 @@ export function LaneGitBar({
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim()) void run("commit", { message });
if (e.key === "Enter" && message.trim() && !busy) void run("commit", { message });
if (e.key === "Escape") setCommitting(false);
}}
placeholder={t("gitBar.commitPlaceholder")}
@@ -233,7 +282,7 @@ export function LaneGitBar({
<Btn
label={t("gitBar.commitAll", { count: dirty })}
busy={busy === "commit"}
disabled={!message.trim()}
disabled={!message.trim() || !!busy}
onClick={() => void run("commit", { message })}
/>
{/* The Changes panel's per-file staging only means something if a
@@ -243,7 +292,7 @@ export function LaneGitBar({
label={t("gitBar.commitStaged")}
title={t("gitBar.commitStagedHint")}
busy={busy === "commit"}
disabled={!message.trim()}
disabled={!message.trim() || !!busy}
onClick={() => void run("commit", { message, only: "staged" })}
/>
<button
@@ -289,8 +338,16 @@ export function LaneGitBar({
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
title={t("gitBar.newLaneHint")}
disabled={!picked}
// A branch any worktree holds — this lane's own included — is
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
title={
picked && (heldBy.has(picked) || picked === git.branch)
? t("gitBar.newLaneHeld", {
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
@@ -245,10 +245,11 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Auto named");
// The placeholder previews the name the server will pick.
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
// the form cannot see, so the placeholder only says the field is optional.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"feat/auto-named"
"Automatic — named from the title"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
@@ -237,6 +237,17 @@ describe("LaneCard git block", () => {
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
});
it("drops the previous lane's facts the moment the lane changes", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
const { rerender } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await screen.findByText(/feat\/rename-metric/);
// The next lane's read never lands: nothing of lane 1's git may linger.
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
rerender(<LaneCard lane={makeLane({ id: 2, branch: "lane/other" })} onAction={vi.fn()} />);
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
});
it("names the files behind the counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
@@ -11,7 +11,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
@@ -94,12 +94,14 @@ beforeEach(() => {
},
],
});
vi.mocked(api.lanes.gitLs).mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitLs)
.mockReset()
.mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -229,6 +231,76 @@ describe("LaneChangesPanel", () => {
});
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
// Lane 8 has a file at the SAME path.
rerender(<LaneChangesPanel laneId={8} />);
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(8, expect.anything()));
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
const [again] = await screen.findAllByRole("button", { name: "Discard this file's changes" });
await user.click(again!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("disarms a pending discard once that path leaves the file list", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
const base = changes();
const rest = base.available ? base.files.slice(1) : [];
vi.mocked(api.lanes.changes).mockResolvedValueOnce(changes({ files: rest }));
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
});
it("ignores a late response for the lane it has already left", async () => {
let resolveOld: (v: LaneChanges) => void = () => {};
vi.mocked(api.lanes.changes).mockImplementation((id) =>
id === 7
? new Promise<LaneChanges>((r) => (resolveOld = r))
: Promise.resolve(
changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
)
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
});
it("opens a commit from history as a commit-range review", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -411,6 +483,16 @@ describe("LaneChangesPanel", () => {
});
});
it("drops a half-typed commit message on a lane switch", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: meant for lane 7");
rerender(<LaneChangesPanel laneId={8} />);
await screen.findByTestId("lane-changes-commit");
expect(screen.getByLabelText("Commit message")).toHaveValue("");
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
@@ -515,6 +597,73 @@ describe("LaneChangesPanel", () => {
);
});
it("opens, not folds, when Enter is pressed on the path", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
screen.getByRole("button", { name: "server/lib/git-ops.js" }).focus();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("drops a quick-open answer for the lane it has already left", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs).mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
rerender(<LaneChangesPanel laneId={8} />);
await act(async () => answer({ paths: ["from/lane7.ts"] }));
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
await user.click(screen.getByRole("button", { name: "Files" }));
expect(screen.queryByText("from/lane7.ts")).toBeNull();
});
it("keeps the newest quick-open answer when an older one lands after it", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs)
.mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
)
.mockResolvedValueOnce({
available: true,
paths: ["new/mainx.ts"],
truncated: false,
total: 1,
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const box = await screen.findByLabelText("Find a file by path…");
await user.type(box, "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
await user.type(box, "x");
expect(await screen.findByText("new/mainx.ts")).toBeInTheDocument();
await act(async () => answer({ paths: ["old/main-only.ts"] }));
expect(screen.queryByText("old/main-only.ts")).toBeNull();
});
it("goes back to the list it came from, at the offset it was left at", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -537,4 +686,152 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
});
it("opens an unchanged tracked file from Files with no error banner", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockRejectedValue(
Object.assign(new Error("file has no changes in this range"), {
code: "ENOFILECHANGE",
status: 400,
})
);
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledTimes(3));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
});
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with at most four tree reads in flight", async () => {
const user = userEvent.setup();
let inFlight = 0;
let peak = 0;
vi.mocked(api.lanes.gitTree).mockClear();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path = "") => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight -= 1;
const depth = path ? path.split("/").length : 0;
const entries =
depth >= 2
? [{ name: "leaf.ts", path: `${path}/leaf.ts`, dir: false }]
: Array.from({ length: 5 }, (_, i) => ({
name: `d${i}`,
path: path ? `${path}/d${i}` : `d${i}`,
dir: true,
}));
return { available: true, path, entries, truncated: false };
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("d0");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
await waitFor(() => expect(screen.getAllByText("leaf.ts")).toHaveLength(25));
// Root + 5 + 25 folders, each fetched exactly once.
expect(api.lanes.gitTree).toHaveBeenCalledTimes(31);
expect(peak).toBeLessThanOrEqual(4);
});
it("does not repaint the previous lane after its git action resolves post-switch", async () => {
const user = userEvent.setup();
let resolveAction: (v: { action: string; ok: true; output: string }) => void = () => {};
vi.mocked(api.lanes.gitAction).mockReturnValue(
new Promise((r) => (resolveAction = r)) as ReturnType<typeof api.lanes.gitAction>
);
vi.mocked(api.lanes.changes).mockImplementation(async (id) =>
id === 7
? changes()
: changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
const lane7Reads = vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7).length;
resolveAction({ action: "stage", ok: true, output: "" });
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7)).toHaveLength(
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all walk outlives a lane switch", async () => {
const user = userEvent.setup();
let resolveSrc: () => void = () => {};
vi.mocked(api.lanes.gitTree).mockImplementation((id, path) => {
if (id === 7 && path === "src")
return new Promise(
(r) =>
(resolveSrc = () =>
r({
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
truncated: false,
}))
);
return Promise.resolve({
available: true,
path: "",
entries: [
id === 7
? { name: "src", path: "src", dir: true }
: { name: "lib8", path: "lib8", dir: false },
],
truncated: false,
});
});
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
rerender(<LaneChangesPanel laneId={8} />);
await user.click(await screen.findByRole("button", { name: "Files" }));
expect(await screen.findByText("lib8")).toBeInTheDocument();
resolveSrc();
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lib8")).toBeInTheDocument();
expect(screen.queryByText("src")).toBeNull();
expect(screen.queryByText("main.ts")).toBeNull();
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
});
});
@@ -3,13 +3,21 @@
* @description Pins the side-by-side renderer: two independently scrolling
* columns (a long line must be reachable, not painted over its neighbour)
* whose horizontal scroll is mirrored, so the two halves of a line stay
* opposite each other.
* opposite each other. Also pins the clamped mirroring (a narrow column may
* not drag the wide one back) and the row windowing of large diffs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { LaneDiffView } from "../LaneDiffView";
import {
LaneDiffView,
LaneFileView,
ROW_PX,
WINDOW_THRESHOLD,
analyseHunk,
visibleRange,
} from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
@@ -33,6 +41,17 @@ const FILE: LaneChangeFile = {
],
};
/** jsdom has no layout: give a scroller the extents a browser would. */
function layout(
el: HTMLElement,
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
) {
for (const [k, v] of Object.entries(g))
Object.defineProperty(el, k, { value: v, configurable: true });
}
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
describe("LaneDiffView — side by side", () => {
it("gives each column its own scroller", () => {
render(<LaneDiffView file={FILE} layout="split" />);
@@ -56,6 +75,8 @@ describe("LaneDiffView — side by side", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollLeft = 320;
fireEvent.scroll(left);
@@ -66,6 +87,55 @@ describe("LaneDiffView — side by side", () => {
expect(left.scrollLeft).toBe(0);
});
it("keeps the split columns on the same row vertically too", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollTop = 480;
fireEvent.scroll(left);
expect(right.scrollTop).toBe(480);
right.scrollTop = 40;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(40);
});
it("clamps the mirrored offset to the other column's own extent", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
right.scrollLeft = 600;
right.scrollTop = 250;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(300);
expect(left.scrollTop).toBe(100);
});
it("does not let the narrow column's clamped echo drag the wide one back", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200 });
right.scrollLeft = 600;
fireEvent.scroll(right);
// A browser fires the narrow column's scroll for the clamped write.
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(600);
// A real move of the narrow column still carries the wide one.
left.scrollLeft = 100;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(100);
});
it("keeps the unified layout on one scroller", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
@@ -158,3 +228,154 @@ describe("LaneDiffView — row styling", () => {
expect(left!.className).not.toMatch(/status-(success|danger)/);
});
});
describe("analyseHunk", () => {
const hunk = {
header: "@@ -1,4 +1,4 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
],
};
it("pairs each del/add with its emphasis, keyed by line index", () => {
const a = analyseHunk(hunk);
expect(a.rows).toHaveLength(3);
expect(a.emph.get(1)).toEqual([6, 7]);
expect(a.emph.get(2)).toEqual([6, 7]);
expect(a.emph.get(3)).toEqual([6, 7]);
expect(a.emph.get(4)).toEqual([6, 7]);
expect(a.emph.has(0)).toBe(false);
});
it("computes once per hunk, so both split columns and re-renders share it", () => {
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
});
});
describe("visibleRange", () => {
it("covers the viewport plus overscan on both sides", () => {
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
});
it("clamps a scrollTop past the content to the last full viewport", () => {
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
});
it("never runs past a short list", () => {
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
});
});
function bigFile(n: number): LaneChangeFile {
const lines = Array.from({ length: n }, (_, i) =>
i % 2
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
);
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
return fileWith(lines);
}
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
describe("LaneDiffView — windowing", () => {
it("renders every row below the threshold, with no spacers", () => {
const { container } = render(
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
);
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
});
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const rows = rowsIn(container);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(300);
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
// header + 5000 lines, every item one fixed row tall
const rendered = container.querySelectorAll("[data-diff-item]").length;
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
});
it("moves the window with the scroll", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
expect(container.textContent).not.toContain("line 3000;");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("line 3000;");
expect(container.textContent).not.toContain("line 10;");
});
it("starts windowing where the reader is when a diff grows past the threshold", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 350 * ROW_PX;
fireEvent.scroll(view);
// Expanding context replaces the file in place, same mounted view.
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
expect(container.textContent).toContain("line 350;");
});
it("starts from the top again after a round trip through split", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
// In split the outer box is no scroller; the browser drops its offset.
screen.getByTestId("lane-diff-view").scrollTop = 0;
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
expect(container.textContent).toContain("line 5;");
});
it("holds the horizontal extent at the longest line, rendered or not", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
// the 300-w line 7 is off screen now, and still sets the width
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
});
it("windows both split columns over the same row range", () => {
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
right.scrollTop = 2000 * ROW_PX;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(2000 * ROW_PX);
const l = rowsIn(left);
const r = rowsIn(right);
expect(l.length).toBeLessThan(300);
expect(l).toHaveLength(r.length);
expect(right.textContent).toContain("line 2001;");
// same rows: the right column's add has its filler row on the left
expect(left.textContent).toContain("line 2000;");
});
it("windows a whole-file read too", () => {
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
expect(rowsIn(container).length).toBeLessThan(300);
const view = screen.getByTestId("lane-file-view");
view.scrollTop = 4000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("row 4000");
});
});
@@ -120,6 +120,15 @@ describe("LaneGitBar", () => {
);
});
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
@@ -146,6 +155,34 @@ describe("LaneGitBar", () => {
expect(onNewLane).toHaveBeenCalledWith("development");
});
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing", "feat/free"],
current: "feat/thing",
worktrees: [
{ path: "/repo/lane3", branch: "feat/thing" },
{ path: "/repo/dev", branch: "development" },
],
});
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const newLane = () => screen.getByRole("button", { name: "New lane here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await user.selectOptions(select, "feat/free");
expect(newLane()).toBeEnabled();
});
it("commits only the index when asked", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
@@ -172,4 +209,115 @@ describe("LaneGitBar", () => {
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
});
it("keeps a half-typed commit message through a fetch", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: half");
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
expect(screen.getByLabelText("Commit message")).toHaveValue("fix: half");
});
it("clears and closes the commit box once the commit lands", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: done");
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
await waitFor(() => expect(screen.queryByLabelText("Commit message")).toBeNull());
});
it("drops the commit box and message on a lane switch, so Enter cannot commit lane B under lane A's message", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: lane three");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Commit message")).toBeNull();
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
expect(screen.getByLabelText("Commit message")).toHaveValue("");
await user.keyboard("{Enter}");
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
});
it("sends one commit for a double Enter", async () => {
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: once{Enter}{Enter}");
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Commit staged" })).toBeDisabled();
});
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
new Promise((r) => {
resolveOld = r;
})
);
const user = userEvent.setup();
const { rerender } = render(
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
const newLane = screen.getByRole("button", { name: "New lane here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
});
it("claims no sync state when the upstream comparison could not be counted", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
onChanged={vi.fn()}
/>
);
expect(screen.queryByText("in sync")).toBeNull();
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
});
it("explains a push with nowhere to go instead of the server's generic text", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
"No remote to push to or pull from"
);
});
});
+4 -2
View File
@@ -25,13 +25,13 @@
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
"addLaneBranchExistingLabel": "Branch to check out",
"addLaneBranchHintAuto": "Leave empty to use the default name shown above.",
"addLaneBranchHintAuto": "Leave empty to let the server name the branch from the title.",
"addLaneBranchLabel": "New branch name",
"addLaneBranchModeCurrent": "Current branch",
"addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.",
"addLaneBranchModeExisting": "Existing branch",
"addLaneBranchModeNew": "New branch",
"addLaneBranchPlaceholder": "feat/my-feature",
"addLaneBranchPlaceholder": "Automatic — named from the title",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
@@ -180,8 +180,10 @@
"gitBar.commitStagedHint": "Commit only what is staged in the Changes panel",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.errNoRemote": "No remote to push to or pull from. Add one, or set this branch's upstream, in a terminal.",
"gitBar.inSync": "in sync",
"gitBar.newLane": "New lane here",
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
"gitBar.noUpstream": "not pushed",
"gitBar.pull": "Pull",
+4 -2
View File
@@ -25,13 +25,13 @@
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
"addLaneBranchExistingLabel": "Nhánh cần checkout",
"addLaneBranchHintAuto": "Để trống để dùng tên mặc định hiển thị ở trên.",
"addLaneBranchHintAuto": "Để trống để server tự đặt tên nhánh theo tiêu đề.",
"addLaneBranchLabel": "Tên nhánh mới",
"addLaneBranchModeCurrent": "Nhánh hiện tại",
"addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.",
"addLaneBranchModeExisting": "Nhánh có sẵn",
"addLaneBranchModeNew": "Nhánh mới",
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
@@ -180,8 +180,10 @@
"gitBar.commitStagedHint": "Chỉ commit những file đã stage ở thẻ Changes",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.errNoRemote": "Không có remote để push hoặc pull. Hãy thêm remote, hoặc đặt upstream cho nhánh này, trong terminal.",
"gitBar.inSync": "đã đồng bộ",
"gitBar.newLane": "Tạo lane mới",
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
"gitBar.noUpstream": "chưa push",
"gitBar.pull": "Pull",