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
+144 -6
View File
@@ -396,10 +396,16 @@ import type {
CostResult,
DashboardEvent,
Lane,
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneFileContent,
LaneTreeEntry,
LaneCounts,
LaneGitFacts,
LaneRuntime,
LaneTool,
LaneWorktree,
ModelPricing,
NamedLock,
PipelineStats,
@@ -500,7 +506,11 @@ export function dashboardToken(): string | null {
* @returns The parsed JSON response body, typed as `T`.
* @throws {Error} When the response status is not ok (non-2xx). The thrown
* message is `body.error.message` if the error body parsed as JSON and carried
* one, otherwise the literal `HTTP <status>`.
* one, otherwise the literal `HTTP <status>`. The server's own `error.code`
* (e.g. `"ERUNLIVE"`) rides along as `.code` on the thrown error — every
* route's error body carries one (see `server/routes/*.js`), and a caller
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = dashboardToken();
@@ -517,7 +527,10 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `HTTP ${res.status}`);
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
});
}
return res.json();
}
@@ -2304,11 +2317,13 @@ export const api = {
/**
* GET /api/lanes/branches — a candidate source repo's local branches.
* @param repo Absolute path to an existing git repository.
* @returns `{ branches, current }` — local branch names (not `origin/*`)
* and which one is currently checked out (null if detached).
* @returns `{ branches, current, worktrees }` — local branch names (not
* `origin/*`), which one is currently checked out (null if detached),
* and the repo's existing worktrees, so the picker can grey out the
* branches git would refuse to check out a second time.
*/
branches: (repo: string) =>
request<{ branches: string[]; current: string | null }>(
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
`/lanes/branches?repo=${encodeURIComponent(repo)}`
),
/**
@@ -2357,6 +2372,129 @@ export const api = {
* readable git repository, which is a normal state rather than an error.
*/
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
/**
* GET /api/lanes/:id/git/changes — the lane's code changes, at the depth
* the Changes panel asks for.
* @param id The lane id.
* @param opts `range` picks the comparison (`work` = uncommitted vs HEAD,
* `branch` = base...HEAD). Pass `path` for one file's hunks, or
* `full: true` for every file's; with neither it is a summary.
* @returns {@link LaneChanges} — `available: false` for a cwd git cannot read.
*/
changes: (
id: number,
opts: {
range?: LaneChangeRange;
path?: string;
full?: boolean;
sha?: string;
context?: number;
} = {}
) => {
const q = new URLSearchParams();
if (opts.range) q.set("range", opts.range);
if (opts.path) q.set("path", opts.path);
if (opts.full) q.set("full", "1");
if (opts.sha) q.set("sha", opts.sha);
if (opts.context != null) q.set("context", String(opts.context));
return request<LaneChanges>(`/lanes/${id}/git/changes?${q.toString()}`);
},
/**
* GET /api/lanes/:id/git/log — the lane's recent commits, newest first.
* @param id The lane id.
* @param opts `file` narrows the list to the commits touching one path.
*/
gitLog: (id: number, opts: { limit?: number; file?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
if (opts.file) q.set("file", opts.file);
return request<{ available: boolean; commits: LaneCommit[] }>(
`/lanes/${id}/git/log?${q.toString()}`
);
},
/**
* GET /api/lanes/:id/git/file — one file's whole content at a revision.
* @param id The lane id.
* @param path Repo-relative path.
* @param rev `work` (the file on disk), `HEAD`, or a commit-ish.
*/
gitFile: (id: number, path: string, rev = "work") =>
request<LaneFileContent>(
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
),
/**
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
* quick-open. Ignored files never appear (it reads `git ls-files`).
* @param id The lane id.
* @param q The substring typed into the quick-open box.
*/
gitLs: (id: number, q: string) =>
request<{ available: boolean; paths: string[]; truncated: boolean; total: number }>(
`/lanes/${id}/git/ls?q=${encodeURIComponent(q)}`
),
/**
* GET /api/lanes/:id/git/tree — one directory level of tracked files.
* @param id The lane id.
* @param path Repo-relative directory; "" for the repo root.
*/
gitTree: (id: number, path = "") =>
request<{
available: boolean;
path: string;
entries: LaneTreeEntry[];
truncated: boolean;
}>(`/lanes/${id}/git/tree?path=${encodeURIComponent(path)}`),
/**
* GET /api/lanes/:id/git/branches — the lane repo's local branches, for
* the branch switcher. The repo comes from the lane row, never the query.
* @param id The lane id.
*/
gitBranches: (id: number) =>
request<{
available: boolean;
branches: string[];
current: string | null;
worktrees: LaneWorktree[];
}>(`/lanes/${id}/git/branches`),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
* `branch-delete`.
* @param body `{message, only}` for commit; `{branch, create}` for switch;
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
* `{paths, confirm: true}` for discard — which is irreversible, so the
* server refuses it without that flag.
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
*/
gitAction: (
id: number,
action:
| "fetch"
| "pull"
| "push"
| "commit"
| "switch"
| "stage"
| "unstage"
| "discard"
| "branch-create"
| "branch-delete",
body: {
message?: string;
only?: "staged";
branch?: string;
base?: string;
create?: boolean;
paths?: string[];
confirm?: boolean;
} = {}
) =>
request<{ action: string; ok: true; output: string; branch?: string }>(
`/lanes/${id}/git/${action}`,
{ method: "POST", body: JSON.stringify(body) }
),
/**
* GET /api/lanes/:id/runtime — the lane's own application stack: slot,
* ports, service liveness, and the last boot error.
@@ -3476,7 +3614,7 @@ export interface DashboardRunHistoryItem {
export interface CwdSuggestion {
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
* = previously used for a run. */
kind: "dashboard" | "home" | "recent";
kind: "dashboard" | "home" | "recent" | "repo";
path: string;
label: string;
}
+113
View File
@@ -2594,6 +2594,15 @@ export type LaneGitFacts =
untracked: number;
/** The files behind the counts, capped; the counts themselves stay exact. */
files: LaneGitFile[];
/** Whether the repo has any remote configured at all. */
hasRemote: boolean;
/** `origin/<branch>`, or null when this branch has never been pushed. */
upstream: string | null;
/** Commits HEAD has that the upstream does not; null without an upstream
* — not 0, which would claim "in sync" about a comparison never made. */
ahead: number | null;
/** Commits the upstream has that HEAD does not; null without an upstream. */
behind: number | null;
}
| { available: false };
@@ -2609,6 +2618,110 @@ export interface LaneGitFile {
deleted: number | null;
}
/** One entry of `git worktree list` for a repo. */
export interface LaneWorktree {
/** Absolute path of the worktree's directory. */
path: string;
/** The branch checked out there; absent for a detached worktree. */
branch?: string;
locked?: boolean;
}
/** Which comparison the Changes panel is showing. */
export type LaneChangeRange = "work" | "branch" | "commit";
/** One row of `GET /api/lanes/:id/git/log`. */
export interface LaneCommit {
sha: string;
short: string;
subject: string;
author: string;
/** ISO 8601 author date. */
date: string;
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
refs: string[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
export type LaneFileContent =
| {
available: true;
path: string;
rev: string;
binary: boolean;
truncated: boolean;
lines: string[];
}
| { available: false };
/** One entry of `GET /api/lanes/:id/git/tree` — a folded directory or a file. */
export interface LaneTreeEntry {
name: string;
/** Path relative to the repo root. */
path: string;
dir: boolean;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
export interface LaneDiffLine {
type: "ctx" | "add" | "del";
/** Line number on the left (before) side; null on an added line. */
oldLine: number | null;
/** Line number on the right (after) side; null on a removed line. */
newLine: number | null;
text: string;
}
/** One `@@` block of a file's diff. */
export interface LaneDiffHunk {
header: string;
/** The function/section name git puts after the `@@`, when it found one. */
context: string;
oldStart: number;
newStart: number;
lines: LaneDiffLine[];
}
/** One changed file, as `GET /api/lanes/:id/git/changes` reports it. */
export interface LaneChangeFile {
path: string;
/** Two-character porcelain code for the `work` range, `"M "`-style for `branch`. */
status: string;
untracked: boolean;
/** The file has changes in the index (`work` range only). */
staged?: boolean;
/** The file has changes only in the working tree (`work` range only). */
unstaged?: boolean;
/** The file is in a merge conflict (`work` range only). */
conflicted?: boolean;
added: number | null;
deleted: number | null;
binary: boolean;
/** Absent in overview mode (no `full`/`path`), `[]` for a binary file. */
hunks?: LaneDiffHunk[];
/** This file's patch was cut short by a size cap. */
truncated?: boolean;
}
/** `GET /api/lanes/:id/git/changes`. */
export type LaneChanges =
| {
available: true;
range: LaneChangeRange;
/** The lane's base branch name, for `range: "branch"`. */
base: string | null;
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
baseRef: string | null;
/** The commit being shown, for `range: "commit"`; its full sha. */
sha: string | null;
/** Lines of unchanged context each hunk carries. */
context: number;
files: LaneChangeFile[];
/** A cap bit: the file list or some patch was cut short. */
truncated: boolean;
}
| { available: false; range: LaneChangeRange; files: LaneChangeFile[] };
/** Aggregate counts across all lanes. */
export interface LaneCounts {
total: number;