feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)

- Changes card beside lane detail with five modes: Files tree, overview,
  Review (whole diff, per-file collapse), single file, commit history.
- LaneDiffView: unified / side-by-side with independent scroll on both axes
  and mirrored scrollLeft, expand/collapse context, intra-line emphasis.
- Staged-only commit box; git actions (fetch/pull/push/stage/unstage/
  discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js.
- Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and
  POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind.
- Add lane: new/existing branch, current branch, worktree list, prefill
  from the git bar's "New lane here"; /api/run/cwds gains a repo kind.
- GlobalTooltip upgrades every native title into a themed anchored bubble;
  chart/info popovers use theme tokens.
This commit is contained in:
2026-09-23 09:18:17 +07:00
parent 32a8887419
commit 1b561c22d9
47 changed files with 7354 additions and 895 deletions
+45 -3
View File
@@ -30,7 +30,7 @@ const STATUS_FILE_CAP = 20;
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(cwd, args) {
async function git(cwd, args, { timeout = 0 } = {}) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
@@ -58,12 +58,16 @@ async function git(cwd, args) {
cwd,
env,
maxBuffer: 8 * 1024 * 1024,
// 0 = no limit, node's own default. A network command (fetch/pull/push)
// passes one so an unreachable remote fails instead of pinning a request
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
timeout,
});
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
const error = new Error(`git ${args[0]} failed`);
error.code = err.code;
error.git = { args, code: err.code, stderr: err.stderr };
error.git = { args, code: err.code, stderr: err.stderr, stdout: err.stdout };
throw error;
}
}
@@ -640,7 +644,45 @@ async function gitFacts(dir) {
const { dirty, untracked, head, files } = await statusCounts(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked, files };
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
}
/**
* Where this branch stands against its upstream: the upstream's name and how
* many commits each side has that the other does not. `unpushedCount` already
* counted one half of this, but only the destroy guard ever saw it — a lane
* that is three commits ahead of origin looks identical to a pushed one in the
* UI, which is the state most worth seeing before a machine is shut down.
*
* A branch with no upstream (never pushed) reports `upstream: null` and null
* counts — not zeros, which would claim "in sync" about a comparison that was
* never possible. Never throws: these are extra fields on a polled endpoint.
*/
async function trackingFacts(dir) {
let hasRemote = false;
try {
hasRemote = !!(await git(dir, ["remote"])).stdout.trim();
} catch {
/* unreadable repo — the caller already has the facts that matter */
}
try {
const upstream = (
await git(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
).stdout.trim();
const counts = (
await git(dir, ["rev-list", "--left-right", "--count", `${upstream}...HEAD`])
).stdout.trim();
const [behind, ahead] = counts.split(/\s+/).map((n) => Number(n));
// Anything but the two numbers git documents reports as unknown, not as
// NaN: the UI treats a falsy count as "nothing to push", and NaN is falsy,
// so a malformed line would render a diverged branch as in sync.
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
return { hasRemote, upstream, ahead: null, behind: null };
}
return { hasRemote, upstream, ahead, behind };
} catch {
return { hasRemote, upstream: null, ahead: null, behind: null };
}
}
/**