fix(lanes,git): harden the lane git surface and fix client races in the Changes panel
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js): - Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a file name is a file name, never a glob, and odd names parse correctly. - A lane that is a subdirectory of its repo sees and acts on ONLY its own paths (reads, stage/unstage/discard/commit, and gitFacts.files). - Any file named settings.local.json (the lane's router API key) is refused as 403 ESECRET on disk (symlinks resolved) and at every committed rev. - commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is staged (checked with --no-renames, so a move into the lane is caught). - push resolves its remote (upstream → only remote → origin), else 400 ENOREMOTE; unstaging a rename unstages both halves. - Git writes serialize on the repo's git dir, not the lane id, so two lanes on one index cannot collide on index.lock. - git refusing an action (a numeric exit status) answers 409 EGIT with git's stderr instead of a 500 carrying a numeric code; one shared parseNumstat. Client: - Changes panel: no stale answer from a previous lane or an older quick-open query repaints the panel; discard disarms on lane switch; Enter on a file path opens it instead of folding; bounded tree expand-all. - Large diffs (> 500 rows) render only the visible rows, with stable width and in-sync split columns; mirrored split scroll is clamped. - Git bar: no "in sync" without counts, a clear no-remote message, branch list re-read after a switch, and no previous-lane facts after a lane switch. - Tooltips no longer flicker or close on unrelated scrolls.
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -86,6 +86,29 @@ export function LaneGitBar({
|
||||
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.
|
||||
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.
|
||||
const inFlight = useRef(false);
|
||||
if (shownLane !== laneId) {
|
||||
setShownLane(laneId);
|
||||
setBusy(null);
|
||||
setError(null);
|
||||
setCommitting(false);
|
||||
setMessage("");
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
laneNow.current = laneId;
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
@@ -94,17 +117,27 @@ export function LaneGitBar({
|
||||
action: Action,
|
||||
body?: { message?: string; only?: "staged"; branch?: string }
|
||||
) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(laneId, action, body || {});
|
||||
setCommitting(false);
|
||||
setMessage("");
|
||||
await api.lanes.gitAction(lane, action, body || {});
|
||||
// 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") setSwitching(false);
|
||||
else if (action.startsWith("branch-")) {
|
||||
setNewBranch("");
|
||||
if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
@@ -113,19 +146,32 @@ export function LaneGitBar({
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
if (laneNow.current === lane)
|
||||
setError(
|
||||
(err as { code?: string })?.code === "ENOREMOTE"
|
||||
? t("gitBar.errNoRemote")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (laneNow.current === lane) {
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(laneId);
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
setBranches([]);
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,7 +191,10 @@ export function LaneGitBar({
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Ahead/behind first: it is the fact that decides which of the
|
||||
buttons beside it the user actually wants. */}
|
||||
{git.upstream ? (
|
||||
{/* Null counts with an upstream named means git could not compare
|
||||
the two (the upstream ref is gone): claiming "in sync" there
|
||||
would be a lie, so it reads as not pushed. */}
|
||||
{git.upstream && git.ahead != null && git.behind != null ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
@@ -223,7 +272,7 @@ export function LaneGitBar({
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim()) void run("commit", { message });
|
||||
if (e.key === "Enter" && message.trim() && !busy) void run("commit", { message });
|
||||
if (e.key === "Escape") setCommitting(false);
|
||||
}}
|
||||
placeholder={t("gitBar.commitPlaceholder")}
|
||||
@@ -233,7 +282,7 @@ export function LaneGitBar({
|
||||
<Btn
|
||||
label={t("gitBar.commitAll", { count: dirty })}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
disabled={!message.trim() || !!busy}
|
||||
onClick={() => void run("commit", { message })}
|
||||
/>
|
||||
{/* The Changes panel's per-file staging only means something if a
|
||||
@@ -243,7 +292,7 @@ export function LaneGitBar({
|
||||
label={t("gitBar.commitStaged")}
|
||||
title={t("gitBar.commitStagedHint")}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
disabled={!message.trim() || !!busy}
|
||||
onClick={() => void run("commit", { message, only: "staged" })}
|
||||
/>
|
||||
<button
|
||||
@@ -289,8 +338,16 @@ export function LaneGitBar({
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
title={t("gitBar.newLaneHint")}
|
||||
disabled={!picked}
|
||||
// A branch any worktree holds — this lane's own included — is
|
||||
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
|
||||
title={
|
||||
picked && (heldBy.has(picked) || picked === git.branch)
|
||||
? t("gitBar.newLaneHeld", {
|
||||
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
|
||||
Reference in New Issue
Block a user