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:
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user