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:
+144
-6
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user