feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
This commit is contained in:
@@ -4,6 +4,13 @@
|
||||
* by {@link StatCard} for raw metric values and anywhere a compact display needs
|
||||
* a full-precision expansion without cluttering the layout.
|
||||
*
|
||||
* ## Look
|
||||
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
|
||||
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
|
||||
* are visibly one thing. It stays cursor-following (and not anchored like the
|
||||
* global one) because it expands a NUMBER inside a line of text, where the
|
||||
* anchor is a few characters wide and the cursor is the real subject.
|
||||
*
|
||||
* ## Portal rendering
|
||||
* Tooltip content is portaled to `document.body` with `position: fixed` so
|
||||
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
|
||||
@@ -56,6 +63,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { TOOLTIP_SURFACE } from "./Tooltip";
|
||||
|
||||
/** Props for {@link Tip}. */
|
||||
interface TipProps {
|
||||
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
|
||||
{children}
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
style={tipStyle}
|
||||
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
|
||||
>
|
||||
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
|
||||
{raw}
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @file Tooltip.tsx
|
||||
* @description The dashboard's tooltip look, and the one renderer that gives it
|
||||
* to every control in the app.
|
||||
*
|
||||
* ## Why a global renderer instead of a wrapper component
|
||||
* The UI already labels its icon-only controls with the native `title`
|
||||
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* ## The look
|
||||
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
|
||||
* hard to read and lands over the thing it describes. It flips above/below and
|
||||
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
|
||||
* after a short delay so a cursor crossing a toolbar does not strobe, and is
|
||||
* built from the theme's own tokens so it is legible in light mode too (the
|
||||
* old bubble was a hardcoded near-black).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
|
||||
export const TOOLTIP_SURFACE =
|
||||
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
|
||||
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
|
||||
"whitespace-pre-wrap break-words";
|
||||
|
||||
/** How long the pointer must rest on a control before its tooltip appears. */
|
||||
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])";
|
||||
|
||||
type Anchored = { text: string; rect: DOMRect };
|
||||
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
||||
|
||||
/**
|
||||
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
||||
* designed tooltip; nothing else has to change.
|
||||
*/
|
||||
export function GlobalTooltip() {
|
||||
const [tip, setTip] = useState<Anchored | null>(null);
|
||||
const [pos, setPos] = useState<Placed | null>(null);
|
||||
const bubble = useRef<HTMLDivElement>(null);
|
||||
// The element whose `title` is currently parked in `data-tip`, so it can be
|
||||
// put back even if the pointer never leaves it (a route change, a re-render
|
||||
// that unmounts it, Escape).
|
||||
const parked = useRef<HTMLElement | null>(null);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
const text = el.dataset.tip;
|
||||
if (text !== undefined) {
|
||||
el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
parked.current = null;
|
||||
}
|
||||
setTip(null);
|
||||
setPos(null);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(el: HTMLElement, delay: number) => {
|
||||
const text = (el.getAttribute("title") || "").trim();
|
||||
if (!text) return;
|
||||
restore();
|
||||
timer.current = window.setTimeout(() => {
|
||||
// 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;
|
||||
const current = (el.getAttribute("title") || "").trim();
|
||||
if (!current) return;
|
||||
el.dataset.tip = current;
|
||||
el.removeAttribute("title");
|
||||
parked.current = el;
|
||||
setTip({ text: current, rect: el.getBoundingClientRect() });
|
||||
}, delay);
|
||||
},
|
||||
[restore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const target = (e: Event) => {
|
||||
const node = e.target;
|
||||
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
|
||||
};
|
||||
const onOver = (e: PointerEvent) => {
|
||||
const el = target(e);
|
||||
if (el) open(el, SHOW_DELAY_MS);
|
||||
};
|
||||
const onOut = (e: PointerEvent) => {
|
||||
// Moving between children of the same control must not flicker.
|
||||
const el = target(e);
|
||||
const to = e.relatedTarget;
|
||||
if (el && to instanceof Node && el.contains(to)) return;
|
||||
if (el || parked.current) restore();
|
||||
};
|
||||
// Keyboard users get the same tooltip, immediately — a focus ring that
|
||||
// reveals nothing is the whole reason icon-only buttons are hard to learn.
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
const el = target(e);
|
||||
if (el) open(el, 0);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") restore();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerover", onOver);
|
||||
document.addEventListener("pointerout", onOut);
|
||||
document.addEventListener("pointerdown", restore);
|
||||
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);
|
||||
window.addEventListener("resize", restore);
|
||||
return () => {
|
||||
document.removeEventListener("pointerover", onOver);
|
||||
document.removeEventListener("pointerout", onOut);
|
||||
document.removeEventListener("pointerdown", restore);
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
document.removeEventListener("focusout", restore);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("scroll", restore, true);
|
||||
window.removeEventListener("resize", restore);
|
||||
restore();
|
||||
};
|
||||
}, [open, 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(() => {
|
||||
if (!tip || !bubble.current) return;
|
||||
const { width, height } = bubble.current.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = window.innerHeight;
|
||||
const centre = tip.rect.left + tip.rect.width / 2;
|
||||
|
||||
let left = centre - width / 2;
|
||||
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
|
||||
// Below by default; above only when below would run off the screen AND
|
||||
// above actually fits, so a control at the very bottom still gets a bubble.
|
||||
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
|
||||
|
||||
setPos({
|
||||
left,
|
||||
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
|
||||
// The arrow points at the control even after the bubble was clamped.
|
||||
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
|
||||
above,
|
||||
});
|
||||
}, [tip]);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={bubble}
|
||||
role="tooltip"
|
||||
data-testid="global-tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
opacity: pos ? 1 : 0,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
className={TOOLTIP_SURFACE}
|
||||
>
|
||||
{tip.text}
|
||||
{/* Two borders only: a fully bordered diamond draws its other two
|
||||
edges straight across the bubble it is attached to. */}
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
|
||||
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
|
||||
pos?.above ? "border-b border-r" : "border-l border-t"
|
||||
} border-border`}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalTooltip;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @file Tooltip.test.tsx
|
||||
* @description Pins the app-wide tooltip renderer: a control's native `title`
|
||||
* becomes the designed bubble on hover and on keyboard focus, the native
|
||||
* tooltip is suppressed only while the bubble is up and the attribute is put
|
||||
* back afterwards (it is the control's accessible name), and `data-tip-off`
|
||||
* opts out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { GlobalTooltip } from "../Tooltip";
|
||||
|
||||
function setup(extra?: React.ReactNode) {
|
||||
return render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Refresh the working copy">refresh</button>
|
||||
{extra}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
|
||||
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const settle = async () => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
};
|
||||
|
||||
describe("GlobalTooltip", () => {
|
||||
it("shows the bubble after a delay and takes the native title while it is up", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
hover(button);
|
||||
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
});
|
||||
|
||||
it("hides and gives the title back on the way out", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
unhover(button);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("opens immediately on keyboard focus and closes on Escape", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
fireEvent.focusIn(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
|
||||
setup(
|
||||
<button title="Raw value" data-tip-off>
|
||||
opted out
|
||||
</button>
|
||||
);
|
||||
const opted = screen.getByRole("button", { name: "opted out" });
|
||||
|
||||
hover(opted);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(opted).toHaveAttribute("title", "Raw value");
|
||||
});
|
||||
|
||||
it("reads the title through a child element, and ignores an empty one", async () => {
|
||||
setup(
|
||||
<button title="">
|
||||
<span data-testid="blank-child">blank</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
hover(screen.getByTestId("blank-child"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
button.innerHTML = "<span data-testid='deep'>refresh</span>";
|
||||
hover(screen.getByTestId("deep"));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,19 @@ import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool } from "../../lib/types";
|
||||
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
|
||||
@@ -77,11 +89,16 @@ export function AddLaneModal({
|
||||
onClose,
|
||||
onAdded,
|
||||
cwdSuggestions,
|
||||
prefill,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the newly created (possibly still-provisioning) lane. */
|
||||
onAdded: (lane: Lane) => void;
|
||||
/** Fields to open with, for "new lane from this repo" in a lane's git bar.
|
||||
* A branch here means the sibling worktree checks out THAT branch, which
|
||||
* is the existing-branch mode — the reason that mode exists (issue #34). */
|
||||
prefill?: { sourceRepo?: string; branch?: string } | null;
|
||||
/** The same suggestion list the Run form already fetched (dashboard cwd,
|
||||
* home, recently-used paths) — reused rather than fetched a second time. */
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
@@ -92,13 +109,17 @@ export function AddLaneModal({
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
// Two ways to start a worktree lane, and git's own rule decides which is
|
||||
// possible: "new" forks a branch off `base`, "existing" checks out a branch
|
||||
// that is already there — which git allows exactly once across all of a
|
||||
// repo's worktrees, hence the busy markers below.
|
||||
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
|
||||
const [base, setBase] = useState("");
|
||||
const [pipeline, setPipeline] = useState("default");
|
||||
// "native" is the built-in profile, not a null state — see server/lib/ai-routers.js.
|
||||
const [router, setRouter] = useState("native");
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [pipelines, setPipelines] = useState<{ id: string; name: string; nodes: unknown[] }[]>([]);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -117,8 +138,9 @@ export function AddLaneModal({
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBranchMode("new");
|
||||
setBase("");
|
||||
setPipeline("default");
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
@@ -128,23 +150,23 @@ export function AddLaneModal({
|
||||
setSetupResult(null);
|
||||
};
|
||||
|
||||
// The template a lane is created with is the ONLY chance to get it right
|
||||
// from here: nothing else in the UI can change it afterwards, so a lane
|
||||
// silently born on `default` renders an 8-node map for a 16-node workflow.
|
||||
// Fetched on open (templates are file-backed and can change between opens).
|
||||
// A prefill only ever seeds the fields ON OPEN: the user may then change
|
||||
// anything, and re-applying it on every render would fight them for the box.
|
||||
useEffect(() => {
|
||||
if (!open || !prefill) return;
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.branch) {
|
||||
setBranch(prefill.branch);
|
||||
setBranchMode("existing");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// A session that boots on the vendor's API keeps talking to it for its whole
|
||||
// life, so the router has to be picked here, before the lane exists.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
api.lanes
|
||||
.pipelines()
|
||||
.then((r) => {
|
||||
if (!cancelled) setPipelines(r.pipelines);
|
||||
})
|
||||
.catch(() => {
|
||||
// Quiet: the select just falls back to the single `default` option
|
||||
// below, and the lane still gets created.
|
||||
if (!cancelled) setPipelines([]);
|
||||
});
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => {
|
||||
@@ -180,6 +202,7 @@ export function AddLaneModal({
|
||||
const path = sourceRepo.trim();
|
||||
if (!path) {
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBase("");
|
||||
setBranchesError(null);
|
||||
return;
|
||||
@@ -190,6 +213,7 @@ export function AddLaneModal({
|
||||
const r = await api.lanes.branches(path);
|
||||
if (lookedUpFor.current !== path) return; // a newer path superseded this one
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
setBase(r.current || r.branches[0] || "");
|
||||
setBranchesError(null);
|
||||
} catch {
|
||||
@@ -197,6 +221,7 @@ export function AddLaneModal({
|
||||
// Not yet a valid repo path (still being typed, or genuinely wrong) -
|
||||
// quiet by design, the same way CwdAutocomplete never errors either.
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setBase("");
|
||||
setBranchesError(t("addLaneNotARepo"));
|
||||
}
|
||||
@@ -220,7 +245,6 @@ export function AddLaneModal({
|
||||
const result = await api.lanes.ensure({
|
||||
cwd: repo,
|
||||
title: name || undefined,
|
||||
pipeline,
|
||||
tool,
|
||||
});
|
||||
// Report the lane the INSTANT it exists server-side. Everything after
|
||||
@@ -250,14 +274,17 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!branches || !name || !branch.trim()) return;
|
||||
// A blank branch in "new" mode is legal and deliberate: the server names
|
||||
// it `${LANE_BRANCH_PREFIX}${slug}`, which is what the placeholder previews.
|
||||
if (!branches || !name || (branchMode === "existing" && !branch.trim())) return;
|
||||
try {
|
||||
const result = await api.lanes.worktree({
|
||||
sourceRepo: repo,
|
||||
title: name,
|
||||
base: base || undefined,
|
||||
branch: branch.trim(),
|
||||
pipeline,
|
||||
// An existing branch is checked out as it stands; `base` only means
|
||||
// something when a branch is being forked.
|
||||
base: branchMode === "new" ? base || undefined : undefined,
|
||||
branch: branch.trim() || undefined,
|
||||
tool,
|
||||
});
|
||||
onAdded(result.lane);
|
||||
@@ -348,7 +375,16 @@ export function AddLaneModal({
|
||||
const done = !!setupResult;
|
||||
const disabled =
|
||||
!done &&
|
||||
(!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
/** branch name -> the worktree directory already holding it. git allows one
|
||||
* checkout per branch, so these are the branches this form cannot use. */
|
||||
const busyBranches = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
const freeBranches = (branches || []).filter((b) => !busyBranches.has(b));
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
@@ -422,6 +458,37 @@ export function AddLaneModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
for a branch in the dark: the one fact that decides whether a
|
||||
branch can be used at all is which worktree is holding it. */}
|
||||
{mode === "worktree" && worktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="add-lane-worktrees"
|
||||
className="rounded-md border border-border-light bg-surface-0 p-2"
|
||||
>
|
||||
<p className="mb-1 text-[10px] font-medium text-fg-secondary">
|
||||
{t("addLaneWorktreesTitle", { count: worktrees.length })}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{worktrees.slice(0, 8).map((w) => (
|
||||
<li key={w.path} className="flex items-baseline gap-1.5 text-[10px]">
|
||||
<span className="shrink-0 font-mono text-fg-primary">
|
||||
⑂ {w.branch || t("addLaneWorktreeDetached")}
|
||||
</span>
|
||||
<span className="truncate text-fg-muted" title={w.path}>
|
||||
{w.path}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{worktrees.length > 8 && (
|
||||
<li className="text-[10px] text-fg-muted">
|
||||
{t("addLaneWorktreesMore", { count: worktrees.length - 8 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-title">
|
||||
{t("addLaneTitleLabel")}
|
||||
@@ -431,152 +498,215 @@ export function AddLaneModal({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("addLaneTitlePlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-blue-500 focus:outline-none"
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-pipeline">
|
||||
{t("addLanePipelineLabel")}
|
||||
</label>
|
||||
<select
|
||||
id="add-lane-pipeline"
|
||||
value={pipeline}
|
||||
onChange={(e) => setPipeline(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{(pipelines.length ? pipelines : [{ id: "default", name: "default", nodes: [] }]).map(
|
||||
(p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nodes.length ? `${p.name} (${p.nodes.length})` : p.name}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLanePipelineHint")}</p>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active = option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
// The input only goes visually hidden, so keyboard and screen
|
||||
// readers still get a real radio group, and the card carries
|
||||
// its focus ring.
|
||||
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
|
||||
: active
|
||||
? "cursor-pointer border-accent bg-accent/10 text-accent"
|
||||
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-lane-router"
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
// Switching to "AI Router" lands on the first saved
|
||||
// profile; with none saved the radio stays on native and
|
||||
// the select below explains why.
|
||||
setRouter(
|
||||
option.value === "native"
|
||||
? "native"
|
||||
: routers.find((r) => !r.builtin)?.name || "native"
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{router !== "native" && (
|
||||
<select
|
||||
aria-label={t("addLaneRouterProfileLabel")}
|
||||
value={router}
|
||||
onChange={(e) => setRouter(e.target.value)}
|
||||
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{routers
|
||||
.filter((r) => !r.builtin)
|
||||
.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{!routersLoaded
|
||||
? t("addLaneRouterLoading")
|
||||
: routers.some((r) => !r.builtin)
|
||||
? t("addLaneRouterHint")
|
||||
: t("addLaneRouterEmptyHint")}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mode === "worktree" && branchesError && !branches && (
|
||||
<p className="text-[10px] text-status-warning">{branchesError}</p>
|
||||
)}
|
||||
|
||||
{mode === "worktree" && branches && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
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-blue-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHint")}</p>
|
||||
<div data-testid="add-lane-branch-section" className="space-y-2">
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={branchMode === "new"}
|
||||
label={t("addLaneBranchModeNew")}
|
||||
onClick={() => {
|
||||
setBranchMode("new");
|
||||
setBranch("");
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={branchMode === "existing"}
|
||||
label={t("addLaneBranchModeExisting")}
|
||||
onClick={() => {
|
||||
setBranchMode("existing");
|
||||
// Land on something usable rather than on an empty select
|
||||
// that looks like a choice was already made.
|
||||
setBranch((current) =>
|
||||
freeBranches.includes(current) ? current : freeBranches[0] || ""
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* The third answer to "which branch": none. A small or personal
|
||||
project usually wants the lane to BE the repo, working on
|
||||
whatever is checked out (`main`) — which is exactly Repo
|
||||
mode, so this jumps there rather than inventing a second way
|
||||
to do it. git cannot check `main` out in a second worktree
|
||||
while the repo itself holds it, so a worktree lane could not
|
||||
offer this even if it wanted to. */}
|
||||
<Seg
|
||||
active={false}
|
||||
label={t("addLaneBranchModeCurrent")}
|
||||
title={t("addLaneBranchModeCurrentHint")}
|
||||
onClick={() => setMode("repo")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{branchMode === "new" ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-base">
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-branch">
|
||||
{t("addLaneBranchLabel")}
|
||||
</label>
|
||||
<input
|
||||
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")
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-existing">
|
||||
{t("addLaneBranchExistingLabel")}
|
||||
</label>
|
||||
{freeBranches.length === 0 ? (
|
||||
<p className="text-[10px] text-status-warning">{t("addLaneNoFreeBranches")}</p>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-existing"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{branches.map((b) => {
|
||||
const busyAt = busyBranches.get(b);
|
||||
return (
|
||||
// Disabled rather than hidden: "why is my branch not
|
||||
// in the list" is a worse question than seeing it
|
||||
// greyed out with the worktree that holds it.
|
||||
<option key={b} value={b} disabled={!!busyAt}>
|
||||
{busyAt ? t("addLaneBranchBusy", { branch: b, path: busyAt }) : b}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchExistingHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The router is the one per-lane extra this form still asks for, so it
|
||||
sits in the form itself — a disclosure around a single field only
|
||||
hid it. The pipeline picker is gone entirely: every lane is created
|
||||
on `default` (the server's own default) and the template is changed
|
||||
from the lane's own header afterwards. */}
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2">
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
const active =
|
||||
option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
// The input only goes visually hidden, so keyboard and screen
|
||||
// readers still get a real radio group, and the card carries
|
||||
// its focus ring.
|
||||
className={`block flex-1 rounded-lg border p-2.5 text-center text-xs shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
disabled
|
||||
? "cursor-not-allowed border-border bg-surface-2 text-fg-muted opacity-60"
|
||||
: active
|
||||
? "cursor-pointer border-accent bg-accent/10 text-accent"
|
||||
: "cursor-pointer border-border bg-surface-2 text-fg-secondary hover:border-border-light hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-lane-router"
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
// Switching to "AI Router" lands on the first saved
|
||||
// profile; with none saved the radio stays on native and
|
||||
// the select below explains why.
|
||||
setRouter(
|
||||
option.value === "native"
|
||||
? "native"
|
||||
: routers.find((r) => !r.builtin)?.name || "native"
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{router !== "native" && (
|
||||
<select
|
||||
aria-label={t("addLaneRouterProfileLabel")}
|
||||
value={router}
|
||||
onChange={(e) => setRouter(e.target.value)}
|
||||
className="mt-2 w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
{routers
|
||||
.filter((r) => !r.builtin)
|
||||
.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{!routersLoaded
|
||||
? t("addLaneRouterLoading")
|
||||
: routers.some((r) => !r.builtin)
|
||||
? t("addLaneRouterHint")
|
||||
: t("addLaneRouterEmptyHint")}
|
||||
</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setupResult && (
|
||||
<div className="rounded-md border border-border-light bg-surface-0 p-2 space-y-1">
|
||||
<p className="text-[10px] font-medium text-fg-secondary">{t("addLaneSetupTitle")}</p>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
@@ -49,8 +50,11 @@ const LOCKS_REFRESH_MS = 30_000;
|
||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders as a card without a git row.
|
||||
*/
|
||||
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
||||
function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// 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);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -72,9 +76,9 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId]);
|
||||
}, [laneId, nonce]);
|
||||
|
||||
return facts;
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -609,6 +613,7 @@ export default function LaneCard({
|
||||
onAction,
|
||||
childWorktrees,
|
||||
onSelectLane,
|
||||
onNewLaneFromRepo,
|
||||
}: {
|
||||
lane: Lane;
|
||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||
@@ -618,6 +623,9 @@ export default function LaneCard({
|
||||
childWorktrees?: Lane[];
|
||||
/** Jumps the Workspace page's selection to another lane's card. */
|
||||
onSelectLane?: (id: number) => void;
|
||||
/** Opens the Add-lane flow prefilled with this lane's repo, for a sibling
|
||||
* worktree on `branch`. Omitted where that flow is not reachable. */
|
||||
onNewLaneFromRepo?: (repo: string, branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
||||
@@ -639,7 +647,7 @@ export default function LaneCard({
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const [git, refreshGit] = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
|
||||
@@ -809,6 +817,21 @@ export default function LaneCard({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{git?.available && (
|
||||
<LaneGitBar
|
||||
laneId={lane.id}
|
||||
git={git}
|
||||
onChanged={refreshGit}
|
||||
/* A sibling worktree forks from the lane's SOURCE repo when it
|
||||
has one (this lane is itself a worktree of it); an adopted lane
|
||||
is its own repo. */
|
||||
onNewLane={
|
||||
onNewLaneFromRepo
|
||||
? (branch) => onNewLaneFromRepo(lane.source_repo || lane.cwd, branch)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && (
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
/**
|
||||
* @file The Changes panel: the lane's code beside its console, in the five
|
||||
* shapes a review actually needs —
|
||||
*
|
||||
* overview — every changed file with its +/- counts and its stage/discard
|
||||
* actions; click one to open it
|
||||
* review — every file's full diff, stacked, for reading a whole change-set
|
||||
* file — one file, as a diff or as its whole content (issue #35)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
* browse — quick-open by path, or the repo's tracked file tree
|
||||
*
|
||||
* and three ranges: `work` (uncommitted, what the agent has touched right
|
||||
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
|
||||
* `commit` (one commit, reached from history).
|
||||
*
|
||||
* Only the overview polls. Review, file, history and browse are a deliberate
|
||||
* read of a frozen moment — re-fetching under the reader's scroll position is
|
||||
* worse than a slightly stale view, and those are the expensive shapes.
|
||||
*
|
||||
* Rendering lives in LaneDiffView.tsx. Every write goes through
|
||||
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
|
||||
* before the server is told `confirm: true` — it is the one action in this
|
||||
* panel git cannot undo.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Columns2,
|
||||
FileText,
|
||||
FoldVertical,
|
||||
Folder,
|
||||
Minus,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
LaneChangeRange,
|
||||
LaneChanges,
|
||||
LaneCommit,
|
||||
LaneTreeEntry,
|
||||
} from "../../lib/types";
|
||||
|
||||
/** How often the overview re-reads. Summary only — no patch text. */
|
||||
const OVERVIEW_REFRESH_MS = 15_000;
|
||||
/** Context steps the expand button walks through, then the whole file. */
|
||||
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
|
||||
|
||||
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
|
||||
|
||||
type Mode = "overview" | "review" | "file" | "history" | "browse";
|
||||
|
||||
/** Colour per porcelain status letter, matching LaneCard's file panel. */
|
||||
function statusClass(status: string, untracked: boolean) {
|
||||
if (untracked) return "text-status-success";
|
||||
const code = status.trim()[0];
|
||||
if (code === "A") return "text-status-success";
|
||||
if (code === "D") return "text-status-danger";
|
||||
if (code === "R") return "text-accent";
|
||||
return "text-status-warning";
|
||||
}
|
||||
|
||||
/** `+12 −3`, with the halves coloured and a zero side dropped. A binary file
|
||||
* has no line delta at all, so it says `bin` rather than leaving the row's
|
||||
* right edge blank, which reads as "changed by nothing". */
|
||||
function Delta({
|
||||
added,
|
||||
deleted,
|
||||
binary,
|
||||
}: {
|
||||
added: number | null;
|
||||
deleted: number | null;
|
||||
binary?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
if (binary)
|
||||
return <span className="shrink-0 font-mono text-[10px] text-fg-muted">{t("changes.bin")}</span>;
|
||||
return (
|
||||
<span className="shrink-0 font-mono text-[10px]">
|
||||
{added ? <span className="text-status-success">+{added}</span> : null}
|
||||
{added && deleted ? " " : null}
|
||||
{deleted ? <span className="text-status-danger">−{deleted}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One folder level of the browse tree, rendered in place and recursing into
|
||||
* every expanded child — so opening a nested file no longer means navigating
|
||||
* away from its siblings.
|
||||
*/
|
||||
function TreeNodes({
|
||||
path,
|
||||
depth,
|
||||
children,
|
||||
expanded,
|
||||
onToggleDir,
|
||||
onOpenFile,
|
||||
}: {
|
||||
path: string;
|
||||
depth: number;
|
||||
children: Record<string, LaneTreeEntry[]>;
|
||||
expanded: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
}) {
|
||||
const entries = children[path];
|
||||
if (!entries) return null;
|
||||
return (
|
||||
<>
|
||||
{entries.map((e) => (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
className="flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{e.dir ? (
|
||||
expanded.has(e.path) ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3 shrink-0" />
|
||||
)}
|
||||
{e.dir ? (
|
||||
<Folder className="h-3 w-3 shrink-0 text-accent" />
|
||||
) : (
|
||||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{e.name}</span>
|
||||
</button>
|
||||
{e.dir && expanded.has(e.path) && (
|
||||
<ul>
|
||||
<TreeNodes
|
||||
path={e.path}
|
||||
depth={depth + 1}
|
||||
children={children}
|
||||
expanded={expanded}
|
||||
onToggleDir={onToggleDir}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`shrink-0 rounded p-0.5 disabled:opacity-40 ${
|
||||
danger ? "text-fg-muted hover:text-status-danger" : "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneChangesPanel({ laneId }: { laneId: number }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
const [mode, setMode] = useState<Mode>("overview");
|
||||
const [openFile, setOpenFile] = useState<string | null>(null);
|
||||
const [sha, setSha] = useState<string | null>(null);
|
||||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||||
const [layout, setLayout] = useState<DiffLayout>("unified");
|
||||
const [whole, setWhole] = useState(false);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||
const [treeChildren, setTreeChildren] = useState<Record<string, LaneTreeEntry[]>>({});
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<string[] | null>(null);
|
||||
const [text, setText] = useState<{ path: string; lines: string[]; truncated: boolean } | null>(
|
||||
null
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
|
||||
// Files folded shut in review mode. A change-set is read file by file, and
|
||||
// a file already read is in the way of the next one — so the set is what is
|
||||
// COLLAPSED, and a file arriving later is open by default.
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
// Where opening a file came FROM: its mode and how far the list was scrolled.
|
||||
// Reviewing a change-set is a walk down a long list, and landing back at the
|
||||
// top of it after every file is the whole cost of opening one.
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||||
const pendingScroll = useRef<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api.lanes.changes(laneId, {
|
||||
range,
|
||||
...(range === "commit" && sha ? { sha } : {}),
|
||||
...(mode === "file" && openFile ? { path: openFile, context } : {}),
|
||||
...(mode === "review" ? { full: true } : {}),
|
||||
});
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [laneId, range, sha, mode, openFile, context]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "history" || mode === "browse") return;
|
||||
void load();
|
||||
// Only the cheap shape polls; see the file header.
|
||||
if (mode !== "overview") return;
|
||||
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, mode]);
|
||||
|
||||
// History and the file tree are their own reads, each fetched when its mode
|
||||
// is entered rather than alongside the diff nobody is looking at.
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
void api.lanes
|
||||
.gitLog(laneId, { limit: 50 })
|
||||
.then((r) => setCommits(r.commits))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
|
||||
void api.lanes
|
||||
.gitTree(laneId, "")
|
||||
.then((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
|
||||
}, [mode, laneId, query]);
|
||||
|
||||
// A folder's children are fetched lazily, the first time it is expanded.
|
||||
const toggleDir = (path: string) => {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
return next;
|
||||
});
|
||||
if (path in treeChildren) return;
|
||||
void api.lanes
|
||||
.gitTree(laneId, path)
|
||||
.then((r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries })))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
const [treeBusy, setTreeBusy] = useState(false);
|
||||
|
||||
// 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 () => {
|
||||
setTreeBusy(true);
|
||||
const cache = { ...treeChildren };
|
||||
const toExpand = new Set(expandedDirs);
|
||||
const walk = async (path: string) => {
|
||||
let entries = cache[path];
|
||||
if (!entries) {
|
||||
entries = (await api.lanes.gitTree(laneId, 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)));
|
||||
};
|
||||
try {
|
||||
await walk("");
|
||||
setTreeChildren(cache);
|
||||
setExpandedDirs(toExpand);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setTreeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (mode !== "browse" || !q) {
|
||||
setHits(null);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void api.lanes
|
||||
.gitLs(laneId, q)
|
||||
.then((r) => setHits(r.paths))
|
||||
.catch(() => setHits([]));
|
||||
}, 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mode, laneId, query]);
|
||||
|
||||
// The whole-file read is a different endpoint from the diff, so it is its
|
||||
// own fetch — and only while the viewer is actually showing it.
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !whole || !openFile) return;
|
||||
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)
|
||||
)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, whole, openFile, laneId, range, sha]);
|
||||
|
||||
// A lane switch must not leave the previous lane's file open — its path
|
||||
// almost certainly does not exist in the new one.
|
||||
useEffect(() => {
|
||||
setMode("overview");
|
||||
setOpenFile(null);
|
||||
setSha(null);
|
||||
setRange("work");
|
||||
setTreeChildren({});
|
||||
setExpandedDirs(new Set());
|
||||
setCommits(null);
|
||||
setCollapsed(new Set());
|
||||
returnTo.current = null;
|
||||
pendingScroll.current = null;
|
||||
}, [laneId]);
|
||||
|
||||
// Restoring has to wait for the list to be TALL enough again: leaving a file
|
||||
// re-fetches the mode it came from, so the first render back is an empty
|
||||
// box that would swallow the scroll.
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller.current;
|
||||
const want = pendingScroll.current;
|
||||
if (mode === "file" || want == null || !el) return;
|
||||
if (el.scrollHeight - el.clientHeight < want) return;
|
||||
el.scrollTop = want;
|
||||
pendingScroll.current = null;
|
||||
}, [mode, data]);
|
||||
|
||||
const show = (path: string) => {
|
||||
// Opening a file FROM a file (the review list is gone by then) must not
|
||||
// overwrite the list position that is still waiting to be restored.
|
||||
if (mode !== "file") {
|
||||
returnTo.current = { mode, scroll: scroller.current?.scrollTop ?? 0 };
|
||||
}
|
||||
setOpenFile(path);
|
||||
setContext(CONTEXT_STEPS[0]);
|
||||
setWhole(false);
|
||||
setText(null);
|
||||
setMode("file");
|
||||
};
|
||||
|
||||
const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(laneId, action, { paths: [path], ...(confirm ? { confirm } : {}) });
|
||||
setConfirmDiscard(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitStaged = async () => {
|
||||
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" });
|
||||
setMessage("");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCollapsed = (path: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
return next;
|
||||
});
|
||||
|
||||
const files = data?.available ? data.files : [];
|
||||
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
|
||||
const stagedCount = range === "work" ? files.filter((f) => f.staged).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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Two rows on purpose: WHERE you are (mode, and the refresh that always
|
||||
applies) on top, HOW you are looking at it (range, layout, context,
|
||||
whole file) underneath. One row wrapped mid-group as soon as the
|
||||
panel was narrow, which is every panel beside a console. */}
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{mode === "file" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const back = returnTo.current;
|
||||
pendingScroll.current = back?.scroll ?? 0;
|
||||
returnTo.current = null;
|
||||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||||
setOpenFile(null);
|
||||
setWhole(false);
|
||||
}}
|
||||
className="flex items-center gap-0.5 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t("changes.back")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(["browse", "overview", "review", "history"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
aria-pressed={mode === m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
mode === m
|
||||
? "bg-accent text-white"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`changes.mode.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
aria-label={t("changes.refresh")}
|
||||
title={t("changes.refresh")}
|
||||
className="ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode !== "history" && mode !== "browse" && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(["work", "branch"] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
aria-pressed={range === r}
|
||||
onClick={() => {
|
||||
setRange(r);
|
||||
setSha(null);
|
||||
}}
|
||||
title={t(`changes.range.${r}Hint`)}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`changes.range.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
{range === "commit" && sha && (
|
||||
<span
|
||||
data-testid="lane-changes-commit-chip"
|
||||
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{sha.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(mode === "file" || mode === "review") && (
|
||||
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
|
||||
{mode === "review" && (
|
||||
<IconButton
|
||||
label={allCollapsed ? t("changes.expandAll") : t("changes.collapseAll")}
|
||||
disabled={files.length === 0}
|
||||
onClick={() =>
|
||||
setCollapsed(allCollapsed ? new Set() : new Set(files.map((f) => f.path)))
|
||||
}
|
||||
>
|
||||
{allCollapsed ? (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t(`changes.layout.${layout === "unified" ? "split" : "unified"}`)}
|
||||
onClick={() => setLayout(layout === "unified" ? "split" : "unified")}
|
||||
>
|
||||
{layout === "unified" ? (
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Rows3 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
{mode === "file" && (
|
||||
<>
|
||||
{/* Context walks BOTH ways: an expanded diff that cannot
|
||||
be collapsed again leaves the reader re-opening the
|
||||
file to get the tight view back. */}
|
||||
<IconButton
|
||||
label={t("changes.collapseContext")}
|
||||
disabled={whole || context <= CONTEXT_STEPS[0]}
|
||||
onClick={() =>
|
||||
setContext(
|
||||
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
|
||||
CONTEXT_STEPS[0]
|
||||
)
|
||||
}
|
||||
>
|
||||
<FoldVertical className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={t("changes.expandContext")}
|
||||
disabled={whole || context >= LAST_CONTEXT}
|
||||
onClick={() => setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)}
|
||||
>
|
||||
<UnfoldVertical className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "file" && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={whole}
|
||||
onClick={() => setWhole(!whole)}
|
||||
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
|
||||
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{t("changes.wholeFile")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mb-2 whitespace-pre-wrap text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||||
bar sits at the bottom of the visible box instead of at the bottom of
|
||||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||||
a list, and a list scrolls here. */}
|
||||
<div
|
||||
ref={scroller}
|
||||
data-testid="lane-changes-body"
|
||||
className={`flex min-h-0 flex-1 flex-col ${
|
||||
mode === "file" ? "overflow-hidden" : "overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history" className="space-y-0.5">
|
||||
{(commits ?? []).map((c) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSha(c.sha);
|
||||
setRange("commit");
|
||||
setMode("review");
|
||||
}}
|
||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{c.short}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-fg-primary">
|
||||
{c.subject}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1.5 pl-[3.2rem] text-[10px] text-fg-muted">
|
||||
<span className="truncate">{c.author}</span>
|
||||
<span>{new Date(c.date).toLocaleString()}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{commits && commits.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||||
)}
|
||||
</ul>
|
||||
) : mode === "browse" ? (
|
||||
<div data-testid="lane-changes-browse">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("changes.quickOpen")}
|
||||
aria-label={t("changes.quickOpen")}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
{!hits && (
|
||||
<IconButton
|
||||
label={
|
||||
expandedDirs.size > 0
|
||||
? t("changes.collapseAllFolders")
|
||||
: t("changes.expandAllFolders")
|
||||
}
|
||||
disabled={treeBusy}
|
||||
onClick={() =>
|
||||
expandedDirs.size > 0 ? setExpandedDirs(new Set()) : void expandAllDirs()
|
||||
}
|
||||
>
|
||||
{expandedDirs.size > 0 ? (
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{hits ? (
|
||||
<ul className="space-y-0.5">
|
||||
{hits.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
show(p);
|
||||
setWhole(true);
|
||||
}}
|
||||
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{hits.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.noMatches")}</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
<TreeNodes
|
||||
path=""
|
||||
depth={0}
|
||||
children={treeChildren}
|
||||
expanded={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
onOpenFile={(p) => {
|
||||
show(p);
|
||||
setWhole(true);
|
||||
}}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : !data ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !data.available ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
|
||||
) : files.length === 0 ? (
|
||||
<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} />
|
||||
))}
|
||||
</ul>
|
||||
{stagedCount > 0 && (
|
||||
<div
|
||||
data-testid="lane-changes-commit"
|
||||
className="mt-2 flex items-center gap-1.5 border-t border-border-light pt-2"
|
||||
>
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
|
||||
}}
|
||||
placeholder={t("changes.commitPlaceholder")}
|
||||
aria-label={t("changes.commitPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !message.trim()}
|
||||
onClick={() => void commitStaged()}
|
||||
className="shrink-0 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.commitStaged", { count: stagedCount })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : mode === "file" ? (
|
||||
current || (whole && text) ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="mb-1 flex items-baseline gap-1.5">
|
||||
<FileText className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
|
||||
{openFile}
|
||||
</span>
|
||||
{current && (
|
||||
<Delta added={current.added} deleted={current.deleted} binary={current.binary} />
|
||||
)}
|
||||
</div>
|
||||
{whole ? (
|
||||
text ? (
|
||||
<LaneFileView
|
||||
path={text.path}
|
||||
lines={text.lines}
|
||||
truncated={text.truncated}
|
||||
fill
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
)
|
||||
) : current ? (
|
||||
<LaneDiffView file={current} layout={layout} fill />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.fileGone")}</p>
|
||||
)
|
||||
) : (
|
||||
<div data-testid="lane-changes-review" className="space-y-3">
|
||||
{files.map((f) => {
|
||||
const shut = collapsed.has(f.path);
|
||||
return (
|
||||
<div key={f.path} className="rounded border border-border-light">
|
||||
{/* The whole header folds the file; only the PATH opens it.
|
||||
A header that opened the file on any click made folding a
|
||||
target-practice exercise against one chevron. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleCollapsed(f.path)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed(f.path);
|
||||
}
|
||||
}}
|
||||
aria-expanded={!shut}
|
||||
aria-label={
|
||||
shut
|
||||
? t("changes.expandFile", { path: f.path })
|
||||
: t("changes.collapseFile", { path: f.path })
|
||||
}
|
||||
className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
|
||||
shut ? "" : "border-b border-border-light"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 self-center text-fg-muted transition-transform ${
|
||||
shut ? "" : "rotate-90"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`shrink-0 font-mono text-[11px] font-semibold ${statusClass(f.status, f.untracked)}`}
|
||||
>
|
||||
{f.untracked ? "A" : f.status.trim()[0] || "M"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
show(f.path);
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-fg-primary hover:underline"
|
||||
title={t("changes.openFile", { path: f.path })}
|
||||
>
|
||||
{f.path}
|
||||
</button>
|
||||
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
|
||||
</div>
|
||||
{!shut && <LaneDiffView file={f} layout={layout} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.available && data.truncated && mode !== "history" && mode !== "browse" && (
|
||||
<p className="mt-2 text-[10px] text-status-warning">{t("changes.truncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneChangesPanel;
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @file The diff renderer behind the Changes panel (issue #35): one file's
|
||||
* hunks as a VS Code-style diff — unified or side-by-side, syntax-highlighted,
|
||||
* with the changed span inside a modified line picked out.
|
||||
*
|
||||
* Three deliberate choices:
|
||||
* - Highlighting reuses `lib/highlight.ts`, the tokenizer the conversation
|
||||
* view already ships. No new dependency for a panel that renders a few
|
||||
* hundred lines.
|
||||
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
||||
* right for the case that matters (one edited span on a line), degrades to
|
||||
* "the whole line changed" otherwise, and costs nothing.
|
||||
* - 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
|
||||
export type DiffLayout = "unified" | "split";
|
||||
|
||||
/**
|
||||
* How tall a diff box may be. `fill` (the single-file view) takes the height
|
||||
* its parent gives it; otherwise — the review list, which stacks many files —
|
||||
* each file is capped so its own horizontal scrollbar stays on screen.
|
||||
*/
|
||||
function boxHeight(fill: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
function langFor(filePath: string) {
|
||||
const ext = filePath.split(".").pop() || "";
|
||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||
}
|
||||
|
||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||
if (emph) {
|
||||
const [from, to] = emph;
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-white/15">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{highlight(text, lang).map((tok, i) => (
|
||||
<span key={i} className={tokenClass(tok.type)}>
|
||||
{tok.text}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The changed span of a del/add pair, as `[start, end]` offsets into each
|
||||
* side, or null when the lines share nothing worth pointing at.
|
||||
*/
|
||||
function emphasis(
|
||||
del: string,
|
||||
add: string
|
||||
): { del: [number, number]; add: [number, number] } | null {
|
||||
if (del === add) return null;
|
||||
let start = 0;
|
||||
const max = Math.min(del.length, add.length);
|
||||
while (start < max && del[start] === add[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
||||
// Nothing in common at either edge: the whole line is the change, and
|
||||
// highlighting all of it adds noise on top of the row tint.
|
||||
if (start === 0 && end === 0) return null;
|
||||
return {
|
||||
del: [start, del.length - end],
|
||||
add: [start, add.length - end],
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 }> = [];
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
out.push({ left: line, right: line });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function Gutter({ n }: { n: number | null }) {
|
||||
return (
|
||||
<span className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60">{n ?? ""}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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>;
|
||||
}
|
||||
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
if (type === "add") return "bg-status-success/10";
|
||||
if (type === "del") return "bg-status-danger/10";
|
||||
return "";
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
const at = hunk.header.split("@@")[1];
|
||||
return (
|
||||
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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 text-fg-muted">
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph.get(i)} />
|
||||
</span>
|
||||
</Row>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One side of the split view: a hunk header, then that side's rows. */
|
||||
function SplitColumn({
|
||||
hunks,
|
||||
lang,
|
||||
side,
|
||||
}: {
|
||||
hunks: LaneDiffHunk[];
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Both sides, each scrolling horizontally on its own but kept in step. */
|
||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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") => {
|
||||
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;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`flex ${boxHeight(fill)}`}>
|
||||
<div
|
||||
ref={left}
|
||||
data-testid="lane-diff-split-left"
|
||||
onScroll={() => sync("left")}
|
||||
className="w-1/2 min-w-0 overflow-auto"
|
||||
>
|
||||
<SplitColumn hunks={hunks} lang={lang} side="left" />
|
||||
</div>
|
||||
<div
|
||||
ref={right}
|
||||
data-testid="lane-diff-split-right"
|
||||
onScroll={() => sync("right")}
|
||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||||
>
|
||||
<SplitColumn hunks={hunks} lang={lang} side="right" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One file's diff, in the chosen layout. */
|
||||
export function LaneDiffView({
|
||||
file,
|
||||
layout,
|
||||
fill = false,
|
||||
}: {
|
||||
file: LaneChangeFile;
|
||||
layout: DiffLayout;
|
||||
/** Take the parent's height (single-file view) instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(file.path);
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
if (!file.hunks || file.hunks.length === 0)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
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} />
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole file's text (no diff), for the viewer's "read the file" mode. */
|
||||
export function LaneFileView({
|
||||
path,
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
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>
|
||||
))}
|
||||
{truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneDiffView;
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, commit (everything, or only what the Changes panel has
|
||||
* staged), switch branch, and create/delete a branch. Per-file staging and
|
||||
* discard live in the Changes panel, beside the file they act on.
|
||||
*
|
||||
* It renders beside the read-only git line in LaneCard rather than inside the
|
||||
* ⋯ menu: that menu holds the rare and dangerous (`reset worktree`), and these
|
||||
* are the opposite — the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
GitBranch,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action = "fetch" | "pull" | "push" | "commit" | "switch" | "branch-create" | "branch-delete";
|
||||
|
||||
function Btn({
|
||||
label,
|
||||
icon,
|
||||
busy,
|
||||
disabled,
|
||||
title,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || busy}
|
||||
title={title}
|
||||
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneGitBar({
|
||||
laneId,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts;
|
||||
/** Called after any action that could have moved the working copy. */
|
||||
onChanged: () => void;
|
||||
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
|
||||
* a branch this bar just created. Omitted where no such flow is reachable. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState<Action | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
|
||||
const run = async (
|
||||
action: Action,
|
||||
body?: { message?: string; only?: "staged"; branch?: string }
|
||||
) => {
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(laneId, action, body || {});
|
||||
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("");
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
}
|
||||
onChanged();
|
||||
} 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));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(laneId);
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
setBranches([]);
|
||||
}
|
||||
};
|
||||
|
||||
const openSwitch = async () => {
|
||||
setSwitching((v) => !v);
|
||||
setError(null);
|
||||
if (branches) return;
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="lane-git-bar" className="mb-2 space-y-1">
|
||||
<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 ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
className="flex items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||
>
|
||||
{(git.ahead ?? 0) > 0 && (
|
||||
<span className="flex items-center text-status-warning">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{git.ahead}
|
||||
</span>
|
||||
)}
|
||||
{(git.behind ?? 0) > 0 && (
|
||||
<span className="flex items-center text-accent">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
{git.behind}
|
||||
</span>
|
||||
)}
|
||||
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
|
||||
</span>
|
||||
) : (
|
||||
git.hasRemote && (
|
||||
<span className="font-mono text-[11px] text-fg-muted">{t("gitBar.noUpstream")}</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<Btn
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3 w-3" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3 w-3" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.push")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pushHint")}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.commit")}
|
||||
icon={<Check className="h-3 w-3" />}
|
||||
disabled={dirty === 0 || !!busy}
|
||||
title={dirty === 0 ? t("gitBar.commitNothing") : t("gitBar.commitHint")}
|
||||
onClick={() => {
|
||||
setCommitting((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3 w-3" />}
|
||||
disabled={!!busy}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{committing && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim()) void run("commit", { message });
|
||||
if (e.key === "Escape") setCommitting(false);
|
||||
}}
|
||||
placeholder={t("gitBar.commitPlaceholder")}
|
||||
aria-label={t("gitBar.commitPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.commitAll", { count: dirty })}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
onClick={() => void run("commit", { message })}
|
||||
/>
|
||||
{/* The Changes panel's per-file staging only means something if a
|
||||
commit can take the index as it stands. The server answers
|
||||
ENOCHANGES when nothing is staged, and that text lands below. */}
|
||||
<Btn
|
||||
label={t("gitBar.commitStaged")}
|
||||
title={t("gitBar.commitStagedHint")}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
onClick={() => void run("commit", { message, only: "staged" })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("gitBar.cancel")}
|
||||
onClick={() => setCommitting(false)}
|
||||
className="text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
const held = heldBy.get(b);
|
||||
return (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Btn
|
||||
label={t("gitBar.switchApply")}
|
||||
busy={busy === "switch"}
|
||||
// A branch git already has checked out somewhere else cannot be
|
||||
// switched to — git allows one worktree per branch.
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
title={t("gitBar.newLaneHint")}
|
||||
disabled={!picked}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
label={t("gitBar.branchDelete")}
|
||||
icon={<Trash2 className="h-3 w-3" />}
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() => void run("branch-delete", { branch: picked })}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
aria-label={t("gitBar.branchNewPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.branchCreate")}
|
||||
icon={<Plus className="h-3 w-3" />}
|
||||
busy={busy === "branch-create"}
|
||||
disabled={!newBranch.trim()}
|
||||
// Creating does not switch: the point is usually a sibling lane,
|
||||
// and moving this pane's HEAD under a working agent is not.
|
||||
title={t("gitBar.branchCreateHint")}
|
||||
onClick={() => void run("branch-create", { branch: newBranch.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
data-testid="lane-git-bar-error"
|
||||
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
|
||||
>
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LaneGitBar;
|
||||
@@ -109,18 +109,6 @@ async function fillWorktreeForm(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.pipelines)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
pipelines: [
|
||||
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||
{
|
||||
id: "ship-feature",
|
||||
name: "Ship feature (lane pipeline)",
|
||||
nodes: new Array(16).fill({ id: "n" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
@@ -181,8 +169,101 @@ function savedRouter(over: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
const withWorktrees = {
|
||||
branches: ["main", "feat/other", "feat/held"],
|
||||
current: "main",
|
||||
worktrees: [
|
||||
{ path: "/Users/tester/projects/repo", branch: "main" },
|
||||
{ path: "/lanes/repo__held", branch: "feat/held" },
|
||||
],
|
||||
};
|
||||
|
||||
it("lists the repo's existing worktrees once the path resolves", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
|
||||
const panel = await screen.findByTestId("add-lane-worktrees");
|
||||
expect(panel).toHaveTextContent("/lanes/repo__held");
|
||||
expect(panel).toHaveTextContent("feat/held");
|
||||
});
|
||||
|
||||
it("checks an existing branch out, sending no base", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "Reuse a branch");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
const select = await screen.findByLabelText("Branch to check out");
|
||||
await user.selectOptions(select, "feat/other");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: "feat/other", base: undefined })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("greys out a branch another worktree already holds", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.click(screen.getByRole("button", { name: "Existing branch" }));
|
||||
|
||||
const held = await screen.findByRole("option", { name: /feat\/held/ });
|
||||
expect(held).toBeDisabled();
|
||||
expect(held).toHaveTextContent("/lanes/repo__held");
|
||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("lets the server name a new branch when the field is left empty", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
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.
|
||||
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"feat/auto-named"
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.worktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: undefined, base: "main" })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name are all present", () => {
|
||||
// The branch NAME is no longer part of this list: an empty one means "let
|
||||
// the server name it" (issue #34).
|
||||
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
@@ -191,6 +272,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main", "feat/other"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
@@ -223,7 +305,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -239,7 +325,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
branch: "feat/new-feature",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
});
|
||||
});
|
||||
@@ -253,7 +338,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("shows a server error and leaves the modal open instead of closing silently", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
@@ -272,7 +361,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -291,7 +384,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("still calls onAdded even when every setup call fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -311,7 +408,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -334,7 +435,11 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({
|
||||
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
|
||||
});
|
||||
@@ -397,7 +502,6 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
title: "main repo",
|
||||
pipeline: "default",
|
||||
tool: "claude",
|
||||
})
|
||||
);
|
||||
@@ -591,50 +695,6 @@ describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||
// any screen.
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||
).toBeInTheDocument();
|
||||
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.selectOptions(select, "ship-feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pipeline: "ship-feature" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||
renderModal();
|
||||
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() => expect(select).toHaveValue("default"));
|
||||
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — tool selection", () => {
|
||||
it("sends the picked tool when adopting a repo", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
@@ -679,3 +739,41 @@ describe("AddLaneModal — tool selection", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["development", "main"],
|
||||
current: "development",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("jumps to Repo mode from the branch choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByTestId("add-lane-branch-section");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Current branch" }));
|
||||
|
||||
// Repo mode adopts the directory as it stands — no branch section left to
|
||||
// answer, because there is no fork to configure.
|
||||
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("prefills the repo and branch it was opened with", async () => {
|
||||
renderModal({
|
||||
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
|
||||
});
|
||||
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
|
||||
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,6 +216,12 @@ describe("LaneCard git block", () => {
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* @file LaneChangesPanel.test.tsx
|
||||
* @description Pins the Changes panel's five modes and its three ranges: the
|
||||
* overview lists files and nothing else, clicking one opens that file's diff
|
||||
* (and asks the server for THAT path), Review asks for every file's hunks at
|
||||
* once, the range toggle changes which comparison is requested, History opens
|
||||
* one commit, and Files quick-opens a whole file. Also pins the per-file git
|
||||
* writes — including discard needing two clicks before the server hears
|
||||
* `confirm` — and the degrade for a cwd git cannot read.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
changes: vi.fn(),
|
||||
gitAction: vi.fn(),
|
||||
gitLog: vi.fn(),
|
||||
gitFile: vi.fn(),
|
||||
gitLs: vi.fn(),
|
||||
gitTree: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const HUNK = {
|
||||
header: "@@ -1,2 +1,3 @@",
|
||||
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: "old line" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 2, text: "new line" },
|
||||
],
|
||||
};
|
||||
|
||||
function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}): LaneChanges {
|
||||
return {
|
||||
available: true,
|
||||
range: "work",
|
||||
base: "development",
|
||||
baseRef: "origin/development",
|
||||
sha: null,
|
||||
context: 3,
|
||||
truncated: false,
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
binary: false,
|
||||
},
|
||||
{ path: "notes.txt", status: "??", untracked: true, added: 4, deleted: 0, binary: false },
|
||||
{
|
||||
path: "docs/API.md",
|
||||
status: "M ",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
vi.mocked(api.lanes.gitAction).mockReset();
|
||||
vi.mocked(api.lanes.gitAction).mockResolvedValue({ action: "stage", ok: true, output: "" });
|
||||
vi.mocked(api.lanes.gitLog).mockResolvedValue({
|
||||
available: true,
|
||||
commits: [
|
||||
{
|
||||
sha: "a".repeat(40),
|
||||
short: "aaaaaaa",
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.gitLs).mockResolvedValue({
|
||||
available: true,
|
||||
paths: ["src/main.ts"],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
});
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneChangesPanel", () => {
|
||||
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
|
||||
expect(screen.getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(screen.getByText("+12")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/new line/)).toBeNull();
|
||||
});
|
||||
|
||||
it("opens one file's diff when its row is clicked, asking for that path only", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
// The highlighter splits a line into one span per token, so assert on the
|
||||
// rendered text of the whole view rather than on a single node.
|
||||
const view = await screen.findByTestId("lane-diff-view");
|
||||
expect(view).toHaveTextContent("new line");
|
||||
// Both gutters are rendered, so a reviewer can cite a line number.
|
||||
expect(view).toHaveTextContent("keep");
|
||||
});
|
||||
|
||||
it("asks for every file's hunks in review mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", full: true })
|
||||
);
|
||||
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches the comparison when the range toggle is used", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "vs base" }));
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||
});
|
||||
|
||||
it("says so, quietly, when the lane's directory is not a git repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(
|
||||
await screen.findByText("This lane's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages one file and asks the server for that path alone", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// Both rows offer it (one modified, one untracked); the first is the
|
||||
// modified file the assertion below names.
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stage!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", 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!);
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Click again to discard — this cannot be undone" })
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a commit from history as a commit-range review", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "commit",
|
||||
sha: "a".repeat(40),
|
||||
full: true,
|
||||
})
|
||||
);
|
||||
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
|
||||
});
|
||||
|
||||
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
|
||||
path === "src"
|
||||
? {
|
||||
available: true,
|
||||
path: "src",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
||||
truncated: false,
|
||||
}
|
||||
: {
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
}
|
||||
);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.click(await screen.findByText("src"));
|
||||
|
||||
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
||||
// The root's "src" row is still there — expanding did not replace the list.
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands every folder at once, fetching whatever level wasn't cached yet", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => {
|
||||
if (path === "src")
|
||||
return {
|
||||
available: true,
|
||||
path: "src",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
||||
truncated: false,
|
||||
};
|
||||
return {
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
};
|
||||
});
|
||||
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" }));
|
||||
|
||||
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Collapse all folders" }));
|
||||
expect(screen.queryByText("main.ts")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("quick-opens a tracked file as its whole content, not a diff", async () => {
|
||||
const user = userEvent.setup();
|
||||
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 user.click(await screen.findByText("src/main.ts"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "work"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
});
|
||||
|
||||
it("switches the diff to side-by-side", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 12,
|
||||
deleted: 3,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||
expect(screen.getByTestId("lane-diff-view")).toHaveAttribute("data-layout", "split");
|
||||
});
|
||||
|
||||
it("expands and then collapses the context again", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||
|
||||
// Collapse is dead at the tightest step, and live once expanded — an
|
||||
// expanded diff that cannot be collapsed sends the reader back out of the
|
||||
// file to get the tight view.
|
||||
expect(screen.getByRole("button", { name: "Show fewer surrounding lines" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 12,
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show fewer surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for more context when the diff is expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByText("server/lib/git-ops.js"));
|
||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 12,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("commits the index from the overview, never the whole tree", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// The box exists only because a file is staged — with a clean index there
|
||||
// is nothing this button could mean.
|
||||
const box = await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
|
||||
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
|
||||
|
||||
expect(box).toBeInTheDocument();
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||
message: "fix: the staged half",
|
||||
only: "staged",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the commit box when nothing is staged", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts Files first in the mode row", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const modes = ["Files", "Overview", "Review", "History"].map((name) =>
|
||||
screen.getByRole("button", { name })
|
||||
);
|
||||
// Source order is the reading order here, so comparing positions pins it.
|
||||
for (let i = 1; i < modes.length; i += 1) {
|
||||
expect(
|
||||
modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("folds one file shut in review mode, and all of them at once", async () => {
|
||||
const user = userEvent.setup();
|
||||
const withHunks = changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
{
|
||||
path: "notes.txt",
|
||||
status: "??",
|
||||
untracked: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(withHunks);
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
|
||||
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(1);
|
||||
// The header stays, so a folded file is still a row you can reopen.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Collapse every file" }));
|
||||
expect(screen.queryAllByTestId("lane-diff-view")).toHaveLength(0);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Expand every file" }));
|
||||
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("opens a file only from its path, and folds it from anywhere else", 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");
|
||||
|
||||
// Anywhere on the header but the path: fold, do not navigate.
|
||||
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
|
||||
expect(screen.getByTestId("lane-changes-review")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
|
||||
// jsdom lays nothing out, so the scrollable box is described by hand —
|
||||
// the restore deliberately waits until the list is tall enough again.
|
||||
const body = screen.getByTestId("lane-changes-body");
|
||||
Object.defineProperty(body, "scrollHeight", { value: 2000, configurable: true });
|
||||
Object.defineProperty(body, "clientHeight", { value: 400, configurable: true });
|
||||
body.scrollTop = 640;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
|
||||
await screen.findByRole("button", { name: /All files/ });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /All files/ }));
|
||||
// Back to REVIEW (where the file was opened from), not to the overview.
|
||||
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file LaneDiffView.test.tsx
|
||||
* @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.
|
||||
* @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 type { LaneChangeFile } from "../../../lib/types";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,2 +1,2 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del", oldLine: 1, newLine: null, text: `const x = "${"a".repeat(400)}";` },
|
||||
{ type: "add", oldLine: null, newLine: 1, text: `const x = "${"b".repeat(400)}";` },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("LaneDiffView — side by side", () => {
|
||||
it("gives each column its own scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
expect(screen.getByTestId("lane-diff-split-left").className).toContain("overflow-auto");
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("bounds its own height so the horizontal bar stays on screen", () => {
|
||||
// A stacked review list caps each file; the single-file view takes the
|
||||
// height its parent gives it. Either way the diff scrolls itself rather
|
||||
// than growing a 2000-line container whose scrollbar is off the bottom.
|
||||
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
||||
|
||||
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("flex-1");
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
|
||||
it("mirrors one column's horizontal scroll onto the other", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
|
||||
left.scrollLeft = 320;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(320);
|
||||
|
||||
right.scrollLeft = 0;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the unified layout on one scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("overflow-auto");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press, that commit sends the typed
|
||||
* message, that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||
return {
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [],
|
||||
hasRemote: true,
|
||||
upstream: "origin/feat/thing",
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
||||
action: "fetch",
|
||||
ok: true,
|
||||
output: "",
|
||||
});
|
||||
vi.mocked(api.lanes.gitBranches)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneGitBar", () => {
|
||||
it("shows both sides of the upstream comparison", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
const tracking = screen.getByTestId("lane-git-tracking");
|
||||
expect(tracking).toHaveTextContent("2");
|
||||
expect(tracking).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("disables the network actions when the repo has no remote", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
||||
// Local actions stay available — a repo with no remote still commits.
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("commits with the typed message and reports the change upward", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
|
||||
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no commit when nothing is uncommitted", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
// Picking is harmless; applying is what a dirty tree blocks.
|
||||
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches branch on a clean tree", 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/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await user.selectOptions(select, "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ dirty: 0, untracked: 0 })}
|
||||
onChanged={vi.fn()}
|
||||
onNewLane={onNewLane}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
|
||||
await user.click(screen.getByRole("button", { name: "Create" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
||||
branch: "feat/sibling",
|
||||
})
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "New lane here" }));
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
|
||||
it("commits only the index when asked", 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: staged only");
|
||||
await user.click(screen.getByRole("button", { name: "Commit staged" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
|
||||
message: "fix: staged only",
|
||||
only: "staged",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("prints git's own rejection text when an action fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
|
||||
);
|
||||
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("non-fast-forward");
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
ShieldAlert,
|
||||
X,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Home,
|
||||
History as HistoryIcon,
|
||||
Search,
|
||||
@@ -449,7 +450,10 @@ export function CwdAutocomplete({
|
||||
// Group suggestions by kind preserving fixed order — home first, matching
|
||||
// the neutral default the page pre-fills (issue #202).
|
||||
const groups = useMemo(() => {
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "recent"];
|
||||
// "repo" (source repos lanes were cut from) sits above the recents: on the
|
||||
// Add-lane form it is the group the field is actually asking for, and on
|
||||
// the Run form it is a short, deliberate list rather than history.
|
||||
const order: CwdSuggestion["kind"][] = ["home", "dashboard", "repo", "recent"];
|
||||
return order
|
||||
.map((kind) => ({ kind, items: filtered.filter((s) => s.kind === kind) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
@@ -525,6 +529,8 @@ export function CwdAutocomplete({
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
) : g.kind === "home" ? (
|
||||
<Home className="w-3 h-3" />
|
||||
) : g.kind === "repo" ? (
|
||||
<GitBranch className="w-3 h-3" />
|
||||
) : (
|
||||
<HistoryIcon className="w-3 h-3" />
|
||||
)}
|
||||
|
||||
@@ -407,7 +407,7 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
{/* Hover tooltip (matches the app's other chart tooltips) */}
|
||||
{tip && (
|
||||
<div
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-[#2a2a4a] bg-[#12121f] px-2.5 py-1.5 text-xs shadow-xl"
|
||||
className="fixed z-50 pointer-events-none rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-xs shadow-xl"
|
||||
style={{
|
||||
left: tip.x > window.innerWidth - 220 ? tip.x - 14 : tip.x + 14,
|
||||
top: tip.y - 10,
|
||||
|
||||
@@ -853,7 +853,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
aria-hidden="true"
|
||||
className="fixed z-50 px-3 py-2 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl pointer-events-none"
|
||||
className="fixed z-50 px-3 py-2 bg-surface-2 border border-border rounded-lg shadow-2xl pointer-events-none"
|
||||
style={{
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
@@ -922,10 +922,11 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
|
||||
row.style.cssText =
|
||||
"display:flex;justify-content:space-between;gap:16px;font-size:11px;line-height:1.6";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.style.color = "#64748b";
|
||||
lbl.style.color = "rgb(var(--fg-muted))";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.style.cssText = "color:#cbd5e1;font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.style.cssText =
|
||||
"color:rgb(var(--fg-primary));font-weight:500;font-variant-numeric:tabular-nums";
|
||||
val.textContent = value;
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(val);
|
||||
@@ -935,12 +936,12 @@ function buildDAGTooltipContent(el: HTMLDivElement, node: DAGNode, t: TFn) {
|
||||
|
||||
const desc = document.createElement("p");
|
||||
desc.style.cssText =
|
||||
"font-size:11px;color:#94a3b8;line-height:1.45;border-top:1px solid #2a2a4a;padding-top:8px;margin:0";
|
||||
"font-size:11px;color:rgb(var(--fg-secondary));line-height:1.45;border-top:1px solid rgb(var(--border));padding-top:8px;margin:0";
|
||||
desc.textContent = meta.description;
|
||||
el.appendChild(desc);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.style.cssText = "font-size:10px;color:#64748b;line-height:1.45;margin:6px 0 0";
|
||||
hint.style.cssText = "font-size:10px;color:rgb(var(--fg-muted));line-height:1.45;margin:6px 0 0";
|
||||
hint.textContent = t("orchestration.tooltip.tipClickToFilter");
|
||||
el.appendChild(hint);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function Tooltip({ state }: { state: TooltipState }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
className="fixed z-50 px-3 py-2 text-xs bg-surface-2 border border-border rounded-lg shadow-xl text-fg-secondary pointer-events-none whitespace-nowrap"
|
||||
style={{
|
||||
left: nearRight ? state.x - 12 : state.x + 12,
|
||||
top: state.y - 10,
|
||||
@@ -144,7 +144,7 @@ function Tooltip({ state }: { state: TooltipState }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1 border-t border-[#2a2a4a]">
|
||||
<div className="mt-1 pt-1 border-t border-border">
|
||||
<span className="font-medium" style={{ color: statusColor(state.item.status) }}>
|
||||
{t(`common:status.${state.item.status}`, { defaultValue: state.item.status })}
|
||||
</span>
|
||||
|
||||
@@ -230,10 +230,10 @@ function InfoPopover({ calculationKey, interp, valueDisplay, metricPhraseKey }:
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: coords.left, top: coords.top, width: POPOVER_W }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-[#2a2a4a]">
|
||||
<div className="flex items-baseline gap-2 mb-2 pb-2 border-b border-border">
|
||||
<span className="text-base font-semibold text-fg-primary tabular-nums">
|
||||
{valueDisplay}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user