Files
AgentHub/client/src/components/lanes/LaneGitHeader.tsx
T
nntrivi2001 daf5b7be92 feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)
- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
2026-10-06 15:38:25 +07:00

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<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);
// 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<LaneGitFacts>({ 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<string, string> = {
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<HTMLDivElement>(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(
<div
ref={panelRef}
data-testid="lane-git-files"
role="group"
aria-label={t("git.filesHeading", { count: total })}
// The pointer has to be able to travel INTO the panel to scroll it, so
// the panel answers the same enter/leave pair the trigger does.
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
style={{
position: "fixed",
zIndex: 60,
top: pos?.top ?? 0,
left: pos?.left ?? 0,
// Hidden for the one frame before it has been measured, so it never
// flashes at the top-left corner.
visibility: pos ? "visible" : "hidden",
}}
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
>
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
<span className="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
<span className="ml-auto shrink-0 font-mono tabular-nums">
<span className="text-status-success">+{added}</span>{" "}
<span className="text-status-danger">−{deleted}</span>
</span>
</div>
<ul className="space-y-0.5 font-mono text-[11px]">
{files.map((f) => {
const code = f.status.trim()[0] || "?";
return (
<li key={f.path} className="flex items-baseline gap-2">
<span
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
title={f.status}
>
{code}
</span>
{/* The DIRECTORY truncates, never the filename: the name is what
you scan for, the path is only there to disambiguate it. */}
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
</span>
<span className="shrink-0 tabular-nums">
{f.added === null && f.deleted === null ? (
<span className="text-fg-muted">{t("git.noDelta")}</span>
) : (
<>
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
<span className="text-status-danger">−{f.deleted ?? 0}</span>
</>
)}
</span>
</li>
);
})}
</ul>
{hidden > 0 &&
(onShowAll ? (
<button
type="button"
data-testid="lane-git-files-more"
onClick={onShowAll}
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
>
{t("git.filesMore", { count: hidden })}
</button>
) : (
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
{t("git.filesMore", { count: hidden })}
</p>
))}
</div>,
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<LaneGitFacts, { available: true }>;
vertical?: boolean;
/** See `LaneGitFilesPanel`. */
onShowAll?: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const [filesOpen, setFilesOpen] = useState(false);
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(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<number | undefined>(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 (
<>
<button
type="button"
ref={setAnchor}
data-testid="lane-git-counts"
aria-expanded={filesOpen}
onMouseEnter={openFiles}
onMouseLeave={closeFiles}
// Focus and click open it too: hover is not a gesture a
// keyboard or a touchscreen has.
onFocus={openFiles}
onBlur={closeFiles}
// Click only OPENS: hover (or a tap's focus) has already opened it,
// and a toggle here shut it again on the same gesture. Leaving,
// blurring or Escape closes it.
onClick={openFiles}
onKeyDown={(e) => {
if (e.key === "Escape") setFilesOpen(false);
}}
className={`cursor-help ${chip}`}
>
{label}
</button>
{filesOpen && git.files?.length > 0 && (
<LaneGitFilesPanel
files={git.files}
hidden={git.dirty + git.untracked - git.files.length}
onShowAll={
onShowAll &&
(() => {
setFilesOpen(false);
onShowAll();
})
}
anchor={anchor}
side={vertical ? "left" : "below"}
onPointerEnter={openFiles}
onPointerLeave={closeFiles}
/>
)}
</>
);
}
/** The counts on the closed Changes rail: its own facts read, polled only
* while the rail is shown (the open panel owns the read otherwise). */
export function LaneGitRailCounts({
laneId,
session,
onShowAll,
}: {
laneId: number;
session?: string;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
const [git] = useLaneGitFacts(laneId, session);
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
}
export function LaneGitHeader({
laneId,
session,
git,
onChanged,
onNewLane,
}: {
laneId: number;
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
/** See `LaneGitBar`'s prop of the same name. */
onNewLane?: (branch?: string) => void;
}) {
if (!git?.available) return null;
const lead = (
<>
{/* One row with the actions: branch first and strongest, then the head
and its subject, which is what truncates when the panel is narrow
(the whole of it stays in the tooltip). */}
<div
data-testid="lane-git"
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
title={`${git.head} ${git.subject}`}
>
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<GitBranch className="h-3 w-3 text-fg-muted" />
{git.branch}
</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
</div>
</>
);
return (
<div data-testid="lane-git-header" className="mb-2 border-b border-border pb-1.5">
<LaneGitBar
laneId={laneId}
session={session}
git={git}
onChanged={onChanged}
onNewLane={onNewLane}
lead={lead}
/>
</div>
);
}