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:
+174
-1
@@ -381,7 +381,11 @@ What the lane's directory looks like to git right now:
|
||||
"files": [
|
||||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||||
]
|
||||
],
|
||||
"hasRemote": true,
|
||||
"upstream": "origin/feat/rename-metric",
|
||||
"ahead": 2,
|
||||
"behind": 1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -393,10 +397,179 @@ What the lane's directory looks like to git right now:
|
||||
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
|
||||
unstaged work together — and are `null` when git has no delta to report: an
|
||||
untracked file, a binary file, a rename, or a repo with no commits yet.
|
||||
- `hasRemote`, `upstream`, `ahead` and `behind` describe the branch's standing
|
||||
against its remote-tracking ref. A branch that has never been pushed reports
|
||||
`upstream: null` with `ahead`/`behind` **null** — not `0`, which would claim
|
||||
"in sync" about a comparison that was never possible.
|
||||
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
|
||||
and the lane list is polled and re-broadcast on every hook. See
|
||||
`docs/LANES.md` for the full treatment.
|
||||
|
||||
#### Read a lane's code changes
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=<file>][&full=1][&context=N]
|
||||
```
|
||||
|
||||
One route, three depths — the shapes the Workspace's Changes panel asks for:
|
||||
|
||||
| Query | Shape | Used by |
|
||||
| --- | --- | --- |
|
||||
| *(none)* | every changed file with its counts, no patch text | overview |
|
||||
| `full=1` | the same list, each file carrying its `hunks` | review |
|
||||
| `path=<file>` | that one file, with its `hunks` | single-file view |
|
||||
| `context=N` | the same, with N unchanged lines around each hunk | the viewer's expand button |
|
||||
|
||||
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
|
||||
untracked files included, `branch` is `<base>...HEAD` for the lane's
|
||||
`base_branch`, and `commit` shows the single commit named by `sha` (an
|
||||
unresolvable one is `400 EBADREV`, never an empty diff).
|
||||
|
||||
`context` defaults to **3** and is clamped to **5000** — at the top of that
|
||||
range the diff has effectively become the whole file, which is what "expand
|
||||
all" means in the panel.
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"range": "work",
|
||||
"base": "development",
|
||||
"baseRef": "origin/development",
|
||||
"sha": null,
|
||||
"context": 3,
|
||||
"truncated": false,
|
||||
"files": [
|
||||
{
|
||||
"path": "server/lib/git-ops.js",
|
||||
"status": " M",
|
||||
"untracked": false,
|
||||
"staged": false,
|
||||
"unstaged": true,
|
||||
"conflicted": false,
|
||||
"added": 12,
|
||||
"deleted": 3,
|
||||
"binary": false,
|
||||
"hunks": [
|
||||
{
|
||||
"header": "@@ -1,2 +1,3 @@",
|
||||
"context": "runGitAction",
|
||||
"oldStart": 1,
|
||||
"newStart": 1,
|
||||
"lines": [{ "type": "add", "oldLine": null, "newLine": 2, "text": " const x = 1;" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Each line is already resolved to both gutters (`oldLine`/`newLine`, one of
|
||||
them `null` on an added or removed line), so a client renders rows without
|
||||
re-parsing `@@` headers.
|
||||
- `staged`, `unstaged` and `conflicted` come from the porcelain XY columns and
|
||||
are reported for `range=work` only — they are what the panel's per-file
|
||||
stage/unstage buttons key off.
|
||||
- An **untracked** file has no git-side "before", so its patch is synthesized
|
||||
as one all-added hunk from the file itself. A binary file reports
|
||||
`binary: true` and `hunks: []`.
|
||||
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
||||
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
||||
the response and on the file it cut.
|
||||
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
||||
git cannot read; `400 ENOBASE` when `range=branch` on a lane with no base
|
||||
branch, and `400 ENOFILECHANGE` for a `path` with no changes in that range.
|
||||
|
||||
#### Read a lane's commits, a file, or the file tree
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
|
||||
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
|
||||
GET /api/lanes/:id/git/ls?q=<substring>
|
||||
GET /api/lanes/:id/git/tree?path=<dir>
|
||||
```
|
||||
|
||||
The reads behind the Changes panel's **History**, whole-file view, quick-open
|
||||
and file explorer. All four are read-only and unguarded, and all four answer
|
||||
`{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
|
||||
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
|
||||
commits touching that path. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
|
||||
`rev=work` (default) is the file on disk — the one branch that reads the
|
||||
filesystem, and it is realpath-confined to the lane directory; anything else
|
||||
goes through `git show <rev>:<path>`. Binary content is reported, never
|
||||
returned. Capped at 1 MB. `400 EBADPATH`, `EBADREV` or `ENOFILE`.
|
||||
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
|
||||
reads `git ls-files`, so ignored files never appear and no skip-list has to
|
||||
be maintained; basename hits rank first.
|
||||
- **`/git/tree`** → `{available, path, entries:[{name, path, dir}], truncated}`
|
||||
— one directory level, tracked entries only, directories folded and listed
|
||||
first. `400 EBADPATH` for a path that escapes the lane.
|
||||
|
||||
#### Read a lane's own branches
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/branches
|
||||
```
|
||||
|
||||
`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}`
|
||||
— the local branches of the lane's own repo, for the branch switcher. The repo
|
||||
comes from the lane row, never from the query (that is what `GET
|
||||
/api/lanes/branches` is for, which takes a candidate path for a lane that does
|
||||
not exist yet).
|
||||
|
||||
#### Act on a lane's working copy
|
||||
|
||||
```http
|
||||
POST /api/lanes/:id/git/:action
|
||||
```
|
||||
|
||||
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
|
||||
per lane under the lane lock.
|
||||
|
||||
| Action | Runs | Body |
|
||||
| --- | --- | --- |
|
||||
| `fetch` | `git fetch --all --prune` | — |
|
||||
| `pull` | `git pull --ff-only` | — |
|
||||
| `push` | `git push -u origin HEAD` | — |
|
||||
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `unstage` | `git restore --staged -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
```
|
||||
|
||||
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
|
||||
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
|
||||
running agent. There is deliberately no merge, rebase, reset or stash here —
|
||||
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
|
||||
only push the branch it is on.
|
||||
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
|
||||
`discard` accept only paths `git status` is reporting as changed right now,
|
||||
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
|
||||
- `discard` is the one irreversible action here and it **requires**
|
||||
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice.
|
||||
- `branch-create` never switches — the usual reason to create one from a lane
|
||||
is to spawn a sibling worktree lane, and moving this pane's HEAD under a
|
||||
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
|
||||
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
|
||||
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
|
||||
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
|
||||
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
|
||||
`EDIRTY`, `ENOCHANGES`); a git failure is
|
||||
**500** with git's own `stderr` appended to `error.message` — a rejection or a
|
||||
credential failure reads better in git's words.
|
||||
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
|
||||
remote fails instead of holding the request open.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
```http
|
||||
|
||||
Reference in New Issue
Block a user