daf5b7be92
- 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
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
/**
|
||
* @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>
|
||
);
|
||
}
|