/** * @file The top of the Changes panel: the lane's working-copy line (branch, * head, subject) and the git action bar under it (`LaneGitBar`); plus the * uncommitted counts drawn vertical on the CLOSED Changes rail * (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed * by the real total, listing the server's first 20, its "… more files" opening * the Changes panel — the open panel lists those files itself, so its header * shows no counts. * * It used to sit in the lane card, a column away from the diff it describes — * a pull there changed files the Changes panel only noticed on its next poll. * Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`, * owned by the panel) and refresh each other after every write. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { GitBranch } from "lucide-react"; import { LaneGitBar } from "./LaneGitBar"; import { api, gitTarget } from "../../lib/api"; import { keepIfSame } from "../../lib/keepIfSame"; import type { LaneGitFacts, LaneGitFile } from "../../lib/types"; /** How often the mounted panel re-reads its working-copy facts. Slow on * purpose: each call is five git subprocesses server-side, and a branch name * does not change on the timescale the lane list is polled at. */ const GIT_REFRESH_MS = 30_000; /** * The lane's own working copy, fetched by the Changes panel rather than folded * into the polled lane list. Absent facts are not an error state: a lane may point at a * plain directory, and it renders with no git header. */ export function useLaneGitFacts( laneId: number, /** Read that session's own worktree instead (`?session=`). */ session?: string ): [LaneGitFacts | null, () => void] { const [facts, setFacts] = useState(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); // A lane switch drops the previous lane's facts during render, so nothing // (the git bar's "current branch" included) shows them until the new read. const [factsLane, setFactsLane] = useState(laneId); if (factsLane !== laneId) { setFactsLane(laneId); setFacts(null); } useEffect(() => { let alive = true; const read = () => { api.lanes .git(gitTarget(laneId, session)) .then((f) => { if (alive) setFacts(keepIfSame(f)); }) .catch(() => { // Silent: a panel that cannot read git shows the rest of itself. An // error banner here would fire on every lane on every server blip. if (alive) setFacts(keepIfSame({ available: false })); }); }; read(); const timer = setInterval(read, GIT_REFRESH_MS); return () => { alive = false; clearInterval(timer); }; }, [laneId, session, nonce]); return [facts, () => setNonce((n) => n + 1)]; } /** Porcelain status code -> the letter shown and the colour it is shown in. * Keyed on the code's first meaningful character, so an index-side change * (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */ const GIT_STATUS_STYLE: Record = { M: "text-status-warning", A: "text-status-success", D: "text-status-danger", R: "text-accent", C: "text-accent", U: "text-status-danger", "?": "text-fg-muted", }; /** * The file list behind a lane's uncommitted counts: which files, what happened * to each, and how many lines it gained and lost. * * It replaced a native `title` tooltip, which could show the same paths but * neither colour them nor align the deltas — and a plain porcelain dump is not * something you read at a glance. * * PORTALLED to `document.body` and `position: fixed`: the Changes panel * scrolls in its own `overflow` box, and an absolutely positioned panel would * be clipped by it. It flips above its anchor rather than off the bottom of * the window, and clamps to the right edge. */ function LaneGitFilesPanel({ files, hidden, onShowAll, anchor, side = "below", onPointerEnter, onPointerLeave, }: { files: LaneGitFile[]; hidden: number; /** Opens the Changes panel, which lists every file the cap left out. */ onShowAll?: () => void; /** The counts button this panel hangs off. */ anchor: HTMLElement | null; /** `left` beside a chip on the right-edge rail, where below would cover it. */ side?: "below" | "left"; onPointerEnter: () => void; onPointerLeave: () => void; }) { const { t } = useTranslation(["lanes"]); const panelRef = useRef(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); // After the first paint, when the panel's real height is known: place it // under the anchor, or above it when that would run off the bottom. useLayoutEffect(() => { if (!anchor || !panelRef.current) return; const a = anchor.getBoundingClientRect(); const p = panelRef.current.getBoundingClientRect(); const gap = 4; if (side === "left") { setPos({ top: Math.max(8, Math.min(a.top, window.innerHeight - p.height - 8)), left: Math.max(8, a.left - p.width - gap), }); return; } const top = a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap; setPos({ top: Math.max(8, top), left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)), }); }, [anchor, files, side]); // Totals are over the LISTED files only — the cap means the rest are unknown, // and a total that silently omits them would be a wrong number rather than a // partial one. `hidden` says how many are missing. const added = files.reduce((n, f) => n + (f.added ?? 0), 0); const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0); const total = files.length + hidden; return createPortal( , document.body ); } /** * The uncommitted counts as a chip that opens the per-file panel on hover, * focus or click — the closed Changes rail, `vertical`, where nothing else * lists the files. */ export function LaneGitCounts({ git, vertical = false, onShowAll, }: { git: Extract; vertical?: boolean; /** See `LaneGitFilesPanel`. */ onShowAll?: () => void; }) { const { t } = useTranslation(["lanes"]); const [filesOpen, setFilesOpen] = useState(false); const [anchor, setAnchor] = useState(null); // Closing is DELAYED: the panel is portalled away from the trigger, so the // pointer leaves the trigger before it can land on the panel it is heading // for. Any enter — trigger or panel — cancels the pending close. const closeTimer = useRef(undefined); const openFiles = useCallback(() => { window.clearTimeout(closeTimer.current); setFilesOpen(true); }, []); const closeFiles = useCallback(() => { window.clearTimeout(closeTimer.current); closeTimer.current = window.setTimeout(() => setFilesOpen(false), 150); }, []); useEffect(() => () => window.clearTimeout(closeTimer.current), []); if (git.dirty === 0 && git.untracked === 0) return null; const label = t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked }); const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${ vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px" }`; return ( <> {filesOpen && git.files?.length > 0 && (