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
+216 -1
View File
@@ -441,11 +441,226 @@ const paths = {
},
],
responses: {
200: { description: "{branches: string[], current: string | null}." },
200: {
description:
"{branches: string[], current: string | null, worktrees: {path, branch?, locked?}[]}. The worktrees ride along so the picker can grey out branches git would refuse to check out twice.",
},
400: { description: "repo is missing, relative, does not exist, or is not a git repo." },
},
},
},
"/api/lanes/{id}/git/changes": {
get: {
tags: ["Lanes"],
summary: "A lane's code changes, at one of three depths",
description:
"Backs the Workspace Changes panel. No extra params = a summary of every changed file; full=1 adds every file's hunks (review mode); path=<file> returns that one file's hunks. range=work (default) is HEAD vs the working tree with untracked files included; range=branch is <base_branch>...HEAD; range=commit needs sha= and shows that one commit. context= widens each hunk (default 3, clamped to 5000) — the viewer's expand button. Lines arrive with both gutters resolved. Capped at 300 files, 200 KB of patch per file and 1 MB per full read; a cap that bit sets `truncated`. Read-only, no same-origin guard.",
operationId: "getLaneChanges",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{
name: "range",
in: "query",
schema: { type: "string", enum: ["work", "branch", "commit"], default: "work" },
},
{
name: "sha",
in: "query",
schema: { type: "string" },
description: "range=commit only: the commit to show.",
},
{
name: "context",
in: "query",
schema: { type: "integer", default: 3, maximum: 5000 },
description: "Unchanged lines around each hunk.",
},
{
name: "path",
in: "query",
schema: { type: "string" },
description: "Return only this file, with its hunks.",
},
{
name: "full",
in: "query",
schema: { type: "string", enum: ["1", "true"] },
description: "Include every file's hunks (review mode).",
},
],
responses: {
200: {
description:
"{available, range, base, baseRef, sha, context, files, truncated}, or {available:false} for a cwd git cannot read.",
},
400: {
description:
"ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.",
},
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/log": {
get: {
tags: ["Lanes"],
summary: "The lane's recent commits",
description:
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
operationId: "listLaneCommits",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "limit", in: "query", schema: { type: "integer", default: 30, maximum: 200 } },
{ name: "file", in: "query", schema: { type: "string" } },
],
responses: {
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/file": {
get: {
tags: ["Lanes"],
summary: "One file's whole content at a revision",
description:
"rev=work (the file on disk, untracked included), HEAD, or a commit-ish. The `work` revision is the only one that reads the filesystem and it is realpath-confined to the lane directory. Binary content is reported, never returned. Capped at 1 MB. Read-only, no same-origin guard.",
operationId: "readLaneFile",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "rev", in: "query", schema: { type: "string", default: "work" } },
],
responses: {
200: { description: "{available, path, rev, binary, truncated, lines}." },
400: { description: "EBADPATH, EBADREV or ENOFILE." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/ls": {
get: {
tags: ["Lanes"],
summary: "Tracked paths matching a substring (quick-open)",
description:
"Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.",
operationId: "listLanePaths",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "q", in: "query", schema: { type: "string" } },
],
responses: {
200: { description: "{available, paths, truncated, total}." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/tree": {
get: {
tags: ["Lanes"],
summary: "One directory level of tracked files",
description:
"The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.",
operationId: "listLaneTree",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "path", in: "query", schema: { type: "string", default: "" } },
],
responses: {
200: { description: "{available, path, entries:[{name, path, dir}], truncated}." },
400: { description: "EBADPATH (the path escapes the lane)." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/branches": {
get: {
tags: ["Lanes"],
summary: "The lane repo's own local branches",
description:
"Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.",
operationId: "listLaneOwnBranches",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "{available, branches, current, worktrees}." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/{action}": {
post: {
tags: ["Lanes"],
summary: "One git write on the lane's working copy",
description:
'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent\'s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.',
operationId: "runLaneGitAction",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{
name: "action",
in: "path",
required: true,
schema: {
type: "string",
enum: [
"fetch",
"pull",
"push",
"stage",
"unstage",
"discard",
"commit",
"switch",
"branch-create",
"branch-delete",
],
},
},
],
requestBody: {
required: false,
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "commit only; required, max 4000 chars." },
only: {
type: "string",
enum: ["staged"],
description: "commit only; commit the index as it stands, with no add -A.",
},
branch: {
type: "string",
description: "switch, branch-create and branch-delete.",
},
base: { type: "string", description: "branch-create only; defaults to HEAD." },
create: { type: "boolean", description: "switch only; create the branch." },
paths: {
type: "array",
items: { type: "string" },
description: "stage, unstage, discard; each must be a path git reports changed.",
},
confirm: {
type: "boolean",
description: "discard only; the call is refused without it.",
},
},
},
},
},
},
responses: {
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
400: {
description:
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.",
},
404: { description: "Lane not found." },
500: { description: "git failed; its stderr is appended to error.message." },
},
},
},
"/api/lanes/{id}/{action}": {
post: {
tags: ["Lanes"],