feat(lanes): move the lane's git section into the Changes panel

- The branch line, the uncommitted-files hover panel and the git action
  row (fetch/pull/push/branch) move from the lane card to the top of the
  Changes panel (new LaneGitHeader.tsx); the card has no git row now.
- Header and panel share one git-facts read: stage/unstage/discard/commit
  and Refresh re-read it, and a git bar action re-reads the diff, the
  history and the file tree.
- The git bar no longer commits: the panel's staged-only box is the one
  commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi).
- "New lane here" is wired from Workspace to the panel.
This commit is contained in:
2026-09-24 11:44:31 +07:00
parent 7bc4594b94
commit faa56f5c75
13 changed files with 612 additions and 621 deletions
@@ -45,6 +45,7 @@ import {
} from "lucide-react";
import { api } from "../../lib/api";
import { LaneDiffView, LaneFileView, type DiffLayout } from "./LaneDiffView";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
LaneChangeFile,
LaneChangeRange,
@@ -64,7 +65,7 @@ const TREE_CONCURRENCY = 4;
type Mode = "overview" | "review" | "file" | "history" | "browse";
/** Colour per porcelain status letter, matching LaneCard's file panel. */
/** Colour per porcelain status letter, matching LaneGitHeader's file panel. */
function statusClass(status: string, untracked: boolean) {
if (untracked) return "text-status-success";
const code = status.trim()[0];
@@ -281,8 +282,21 @@ function ChangeRow({
);
}
export function LaneChangesPanel({ laneId }: { laneId: number }) {
export function LaneChangesPanel({
laneId,
onNewLane,
}: {
laneId: number;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId);
// Bumped by Refresh and by a git bar action: a pull or a switch moves
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
const [range, setRange] = useState<LaneChangeRange>("work");
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
@@ -384,7 +398,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
.gitLog(laneId, { limit: 50 })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId]);
}, [mode, laneId, reread]);
useEffect(() => {
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
@@ -394,7 +408,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
.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]);
}, [mode, laneId, query, reread]);
// A folder's children are fetched lazily, the first time it is expanded.
const toggleDir = (path: string) => {
@@ -555,6 +569,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
await api.lanes.gitAction(lane, action, { paths: [path], ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
@@ -574,6 +589,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
if (laneNow.current !== lane) return;
setMessage("");
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
@@ -582,6 +598,15 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
}
};
const rereadAll = () => {
refreshGit();
// The tree cache is dropped whole: after a switch any folder may differ.
setTreeChildren({});
setExpandedDirs(new Set());
setReread((n) => n + 1);
void load();
};
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
@@ -604,6 +629,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
<LaneGitHeader laneId={laneId} git={git} onChanged={rereadAll} onNewLane={onNewLane} />
{/* 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
@@ -648,7 +674,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
<button
type="button"
onClick={() => void load()}
onClick={rereadAll}
aria-label={t("changes.refresh")}
title={t("changes.refresh")}
className="ml-auto shrink-0 text-fg-muted hover:text-fg-primary"