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
+186 -1
View File
@@ -35,10 +35,13 @@ const {
isGitRepo,
isValidBranchName,
listBranches,
listWorktrees,
resetWorktree,
resolveBase,
slugify,
} = require("../lib/worktree");
const { collectChanges, listCommits, listPaths, listTree, readFileAt } = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { withLaneLock } = require("../lib/lane-lock");
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
const { installAgents } = require("../lib/lane-agents");
@@ -279,7 +282,16 @@ router.get("/branches", async (req, res) => {
}
try {
const { branches, current } = await listBranches(sourceRepo);
res.json({ branches, current });
// The repo's worktrees ride along because every question the Add-lane
// picker asks about a branch is really a question about them: which
// branches can still be checked out (git allows one worktree per branch,
// and `addWorktree` throws EBRANCHBUSY for the rest), and what this repo
// already has provisioned. One extra subprocess on a form the user is
// still typing into, versus a second round trip for facts that expire
// together. A repo whose worktree list cannot be read still answers with
// its branches — the picker just loses the busy markers.
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
res.json({ branches, current, worktrees });
} catch (err) {
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
}
@@ -733,6 +745,179 @@ router.get("/:id/git", async (req, res) => {
}
});
/**
* The lane's code changes, in the three shapes the Workspace's Changes panel
* asks for — one route, because they are the same read at three depths:
*
* ?range=work|branch|commit — overview: every changed file + its counts
* &full=1 — review: the same list WITH every hunk
* &path=<file> — one file's hunks (opened from the overview)
* &context=<n> — lines of context per hunk (expand)
*
* `work` (default) is HEAD vs the working tree, untracked files included;
* `branch` is `<base>...HEAD`, and answers 404-free with ENOBASE when the lane
* has no base branch to measure against; `commit` needs `&sha=` and shows that
* one commit. Read-only, so no same-origin guard — same reasoning as
* GET /:id/git. See server/lib/git-diff.js for the caps.
*/
router.get("/:id/git/changes", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
const full = req.query.full === "1" || req.query.full === "true";
try {
const changes = await collectChanges(lane.cwd, {
range,
base: lane.base_branch,
sha: typeof req.query.sha === "string" ? req.query.sha : null,
file,
full,
context: req.query.context,
});
res.json({ available: true, ...changes });
} catch (err) {
if (["ENOBASE", "ENOFILECHANGE", "EBADRANGE", "EBADREV"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
// Not a git repo (an adopted lane pointed at a plain directory), or a repo
// with no commits yet. Same contract as GET /:id/git: a state, not a fault.
res.json({ available: false, range, files: [] });
}
});
/**
* The lane's recent commits, for the history list. `?file=` narrows it to the
* commits touching one path — the "history of this file" the single-file view
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
*/
router.get("/:id/git/log", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const commits = await listCommits(lane.cwd, {
limit: req.query.limit,
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
});
res.json({ available: true, commits });
} catch {
res.json({ available: false, commits: [] });
}
});
/**
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
* viewer's "show the entire file" mode. Path validation lives in
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
* touches the filesystem, and it is realpath-confined to the lane.
*/
router.get("/:id/git/file", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const file = await readFileAt(
lane.cwd,
typeof req.query.path === "string" ? req.query.path : "",
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
);
res.json({ available: true, ...file });
} catch (err) {
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false });
}
});
/** Tracked paths matching `?q=`, for quick-open. */
router.get("/:id/git/ls", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
res.json({ available: true, ...(await listPaths(lane.cwd, { q: req.query.q })) });
} catch {
res.json({ available: false, paths: [], truncated: false, total: 0 });
}
});
/** One directory level of tracked files, for the file explorer. */
router.get("/:id/git/tree", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const subdir = typeof req.query.path === "string" ? req.query.path : "";
res.json({ available: true, ...(await listTree(lane.cwd, { subdir })) });
} catch (err) {
if (err.code === "EBADPATH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false, path: "", entries: [], truncated: false });
}
});
/**
* The lane's own local branches, for the branch switcher. Separate from
* `GET /branches` (which takes a candidate repo PATH from the query, for a
* lane that does not exist yet); this one resolves the repo from the lane row,
* so no path ever comes from the request.
*/
router.get("/:id/git/branches", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const { branches, current } = await listBranches(lane.cwd);
const worktrees = await listWorktrees(lane.cwd).catch(() => []);
res.json({ available: true, branches, current, worktrees });
} catch {
res.json({ available: false, branches: [], current: null, worktrees: [] });
}
});
/**
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
* discard, commit, switch, branch-create and branch-delete.
* Guarded (they mutate), serialized per lane (`withLaneLock`) so a push and a
* commit fired from two tabs cannot interleave on one index, and thin — every
* rule about what each action may do lives in server/lib/git-ops.js, not here.
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const result = await withLaneLock(lane.id, () =>
runGitAction(lane.cwd, req.params.action, req.body || {})
);
res.json(result);
} catch (err) {
const badRequest = [
"EBADACTION",
"EBADMESSAGE",
"EBADBRANCH",
"ENOBRANCH",
"EBRANCHEXISTS",
"EBRANCHBUSY",
"EDIRTY",
"ENOCHANGES",
"EBADPATH",
"ECONFIRM",
"EPROTECTED",
];
const status = badRequest.includes(err.code) ? 400 : 500;
// git's own stderr is the useful half of a failed push or pull (a
// rejection, a missing credential, a diverged branch), so it joins the
// message rather than riding a side field the client would have to know
// about — `message` is the one thing every caller already reads.
const detail = err.git ? (err.git.stderr || "").trim() : "";
res.status(status).json({
error: {
code: err.code || "EGIT",
message: detail ? `${err.message}\n${detail}` : err.message,
},
});
}
});
/**
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
+22
View File
@@ -106,6 +106,8 @@ router.get("/history", (req, res) => {
* - "home": $HOME (the Run page's default cwd and first autocomplete group —
* a neutral spawn location that doesn't inherit this repo's project
* context, issue #202)
* - "repo": source repos existing lanes were provisioned from — the paths
* the Add-lane form's repo field is actually asking for (issue #34)
* - "recent": distinct cwds Claude Code has been used in, sourced from the
* dashboard's own sessions table. Filtered to dirs that still exist.
*
@@ -126,6 +128,26 @@ router.get("/cwds", (_req, res) => {
push("dashboard", process.cwd(), "Dashboard server");
push("home", require("node:os").homedir(), "Home");
// Source repos lanes have already been provisioned from, newest lane first.
// The Add-lane form's repo field used to offer only "recent" session cwds,
// which for a worktree lane is the WORKTREE, never the repo it was cut from
// — so the one path that form actually wants had to be retyped every time.
// These sit above the recents below because a repo is a deliberate choice a
// user made once, not a directory a session happened to run in.
try {
const { db } = require("../db");
const rows = db
.prepare(
`SELECT source_repo AS p, MAX(id) AS last_id FROM lanes
WHERE source_repo IS NOT NULL AND source_repo <> ''
GROUP BY source_repo ORDER BY last_id DESC LIMIT 20`
)
.all();
for (const row of rows) push("repo", row.p, path.basename(row.p));
} catch {
/* ignore — same best-effort contract as the recents below */
}
// Pull recent cwds from the sessions DB (best-effort; if the DB isn't
// ready or has a different schema, just return what we have).
try {