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
+14 -87
View File
@@ -1,13 +1,14 @@
/**
* @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.
* fetch, pull, push, switch branch, and create/delete a branch. Committing is
* NOT here: it is the Changes panel's staged-only commit box, beside the
* per-file stage/unstage/discard it commits — one way to commit, and it only
* ever takes what the user staged.
*
* 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.
* It renders at the top of the Changes panel (`LaneGitHeader`) rather than in
* the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
* worktree`), and these are 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
@@ -17,21 +18,11 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
ArrowUp,
Check,
GitBranch,
Plus,
RefreshCw,
Trash2,
Upload,
X,
} from "lucide-react";
import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } 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";
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete";
function Btn({
label,
@@ -79,17 +70,15 @@ export function LaneGitBar({
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("");
// LaneCard is not keyed by lane, so this bar outlives a lane switch. All of
// the state above describes ONE lane — a message typed for lane A, Entered
// after switching, would commit B's whole tree under it — so it is reset
// during render the moment the id changes, before anything can act on it.
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
// All of the state above describes ONE lane — a branch picked for lane A,
// applied after switching, would move B's HEAD — so it is reset during
// render the moment the id changes, before anything can act on it.
const [shownLane, setShownLane] = useState(laneId);
// One action at a time, checked synchronously: `busy` is state, and a second
// Enter can land before the render that disables anything.
@@ -98,8 +87,6 @@ export function LaneGitBar({
setShownLane(laneId);
setBusy(null);
setError(null);
setCommitting(false);
setMessage("");
setSwitching(false);
setBranches(null);
setWorktrees([]);
@@ -113,10 +100,7 @@ export function LaneGitBar({
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
const run = async (
action: Action,
body?: { message?: string; only?: "staged"; branch?: string }
) => {
const run = async (action: Action, body?: { branch?: string }) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
@@ -127,12 +111,6 @@ export function LaneGitBar({
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
// Only a landed commit consumes the message; a fetch or pull mid-typing
// must leave it where it was.
if (action === "commit") {
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" || action.startsWith("branch-")) {
@@ -244,16 +222,6 @@ export function LaneGitBar({
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" />}
@@ -265,47 +233,6 @@ export function LaneGitBar({
/>
</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() && !busy) 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() || !!busy}
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() || !!busy}
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