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:
+186
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user