feat: v2.5.11 — cherry-pick, revert, undo and amend in the Changes panel, Open terminal from the AI menu
Git in the Changes panel - Revert any commit, Undo the last commit (soft, changes stay staged) and, with another branch's history open, Cherry-pick onto this branch — all from the history's right-click menu. Amend folds staged changes into the last commit. Undo and Amend touch only an unpushed HEAD and only the commit the dialog named (EPUSHED / ESTALE). - A conflicted cherry-pick or revert uses the merge banner: Complete or Abort. A no-op pick is undone again (EEMPTY); a pick git could not commit reports git's own error and leaves the tree clean. A rebase started in a terminal blocks every history action. - Push, Publish, branch delete, cherry-pick, revert and amend ask first. An armed discard reads as a red "Discard?" chip. There is no bulk discard. - History can show another branch (?ref=). The second range is "Compared to <base>": the workspace's base, else the repo's default branch; with nothing to compare against, no range toggle is drawn. - The diff editor gets each change block's Undo / Keep bar. Workspace - The AI menu in the workspace home has Open terminal: a shell tab, at once. - "+ Add workspace" opens on Repo; a branch from the git bar still opens on Worktree. - The sidebar no longer keeps a list of closed Terminal tabs. - Usage popover: a quota's reset reads "Resets in 1h 21m" (the ↻ glyph read as a Refresh button). Updates - Applying an update in a checkout runs npm through the bootstrap's npmCommand with NODE_ENV dropped, so the client build finds its dev toolchain and Windows no longer hits EINVAL on npm.cmd. Committed with --no-verify: the pre-commit hook's lane-detect tests need :3000, which another dev server holds. Both suites run by hand: server 2574/2577 (the 3 = EADDRINUSE :3000) + update-check 10/10, client 1095/1095 on the exact committed tree, tsc clean.
This commit is contained in:
+2
-2
@@ -386,7 +386,7 @@ graph TD
|
||||
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
|
||||
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||
|
||||
@@ -2224,7 +2224,7 @@ sequenceDiagram
|
||||
| --- | --- |
|
||||
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
|
||||
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
|
||||
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
||||
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
||||
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
||||
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
||||
| **`Sidebar.tsx`** | The update state and its **Check for updates** button live in the connection details modal the sidebar owns (opened from the status bar; the sidebar has no footer); the status bar shows a green dot beside the version while a release is out. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.5.10 (2026-10-07) + server mode (login, per-member rows, Claude account pool) and performance for 10+ concurrent users. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, Open terminal in the AI menu, Repo as the Add-workspace default. Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -44,9 +44,9 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument.
|
||||
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
|
||||
- **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=<data dir>/proxy-settings/<name>.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p <name>` and `$CODEX_HOME/<name>.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`.
|
||||
- **The dashboard's git writes are these seventeen, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort` — nothing else. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- <url>` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is the one history-joining action, and only because its recovery is a button too** (`merge-abort`): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and `reset` already lives behind `worktree.js`'s three-check destroy guard. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet.
|
||||
- **The dashboard's git writes are these twenty-one, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend` — nothing else. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- <url>` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet.
|
||||
- **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `<CLAUDE_HOME>/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=<ours>` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own.
|
||||
- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]`, every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the run form's red per-run switch, never remembered — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request.
|
||||
- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]`, every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the run form's red per-run switch, never remembered — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request.
|
||||
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`). AgentHub never reads the IDE's data.
|
||||
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
|
||||
- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; an npm update is refused (`ENOTNPM`) for a tool whose binary is not under `npm prefix -g` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects. Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name.
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface SelectOption<T extends string> {
|
||||
hint?: string;
|
||||
/** Optional leading icon, shown in the trigger and the list. */
|
||||
icon?: ReactNode;
|
||||
/** Draw a rule above this option: it starts a group of its own. */
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -272,6 +274,8 @@ export function Select<T extends string>({
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
@@ -145,7 +145,7 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
@@ -161,16 +161,9 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
/>
|
||||
</span>
|
||||
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
||||
{resets && (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
<span aria-hidden>{resets}</span>
|
||||
<span className="sr-only">
|
||||
{t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* In words: a ↻ glyph here read as a Refresh button. */}
|
||||
<span className="text-right text-fg-muted">
|
||||
{resets && t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("StatusBar", () => {
|
||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workin use");
|
||||
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||
expect(work).toHaveTextContent("5h95%41m");
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
|
||||
@@ -81,8 +81,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
kill: vi.fn(async () => ({ ok: true })),
|
||||
history: vi.fn(async () => ({ items: [] })),
|
||||
forgetHistory: vi.fn(async () => ({ removed: "" })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
@@ -351,97 +349,6 @@ describe("WorkspaceTree", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => {
|
||||
vi.mocked(api.run.history).mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
items: [
|
||||
{
|
||||
id: "agenthub-lane-1-2",
|
||||
tool: "shell",
|
||||
status: "running",
|
||||
started_at: "2026-10-06T10:00:00Z",
|
||||
ended_at: null,
|
||||
isLive: true,
|
||||
},
|
||||
{
|
||||
id: "agenthub-lane-1-3@a",
|
||||
tool: "shell",
|
||||
status: "killed",
|
||||
started_at: "2026-10-06T09:00:00Z",
|
||||
ended_at: "2026-10-06T09:05:00Z",
|
||||
isLive: false,
|
||||
},
|
||||
{
|
||||
id: "agenthub-lane-1-1@b",
|
||||
tool: "shell",
|
||||
status: "exited",
|
||||
started_at: "2026-10-06T08:00:00Z",
|
||||
ended_at: "2026-10-06T08:05:00Z",
|
||||
isLive: false,
|
||||
},
|
||||
{
|
||||
id: "agenthub-lane-1-1@c",
|
||||
tool: "shell",
|
||||
status: "exited",
|
||||
started_at: "2026-10-06T07:00:00Z",
|
||||
ended_at: "2026-10-06T07:05:00Z",
|
||||
isLive: false,
|
||||
},
|
||||
{
|
||||
id: "agenthub-lane-1-1@d",
|
||||
tool: "shell",
|
||||
status: "exited",
|
||||
started_at: "2026-10-06T06:00:00Z",
|
||||
ended_at: "2026-10-06T06:05:00Z",
|
||||
isLive: false,
|
||||
},
|
||||
],
|
||||
}) as never
|
||||
);
|
||||
vi.mocked(api.lanes.action).mockImplementation(
|
||||
async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a");
|
||||
expect(closed).toHaveTextContent("Terminal");
|
||||
expect(api.run.history).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ laneId: 1, tool: "shell" })
|
||||
);
|
||||
// The live one is not history, and only the newest three are listed.
|
||||
expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull();
|
||||
expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3);
|
||||
await user.click(closed);
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" });
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
|
||||
});
|
||||
|
||||
it("removes a closed terminal from its hover X and its right-click menu", async () => {
|
||||
const shell = (id: string) => ({
|
||||
id,
|
||||
tool: "shell",
|
||||
status: "exited",
|
||||
started_at: "2026-10-06T08:00:00Z",
|
||||
ended_at: "2026-10-06T08:05:00Z",
|
||||
isLive: false,
|
||||
});
|
||||
vi.mocked(api.run.history).mockImplementation(
|
||||
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
|
||||
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
|
||||
await user.click(screen.getByText("Remove from list"));
|
||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
|
||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
|
||||
});
|
||||
|
||||
it("selects by URL, and a click selects and expands", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree("/workspace?lane=2");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file AddLaneModal.tsx
|
||||
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||
* segmented toggle: "Repo" adopts an existing directory as-is via
|
||||
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
|
||||
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
|
||||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||
* main repo you want stage detection on); "Worktree" provisions a
|
||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||
@@ -94,7 +95,7 @@ export function AddLaneModal({
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("repo");
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
@@ -130,7 +131,7 @@ export function AddLaneModal({
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setMode("worktree");
|
||||
setMode("repo");
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
@@ -151,6 +152,8 @@ export function AddLaneModal({
|
||||
if (!open || !prefill) return;
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.branch) {
|
||||
// A branch only means something to a worktree.
|
||||
setMode("worktree");
|
||||
setBranch(prefill.branch);
|
||||
setBranchMode("existing");
|
||||
}
|
||||
|
||||
@@ -11,16 +11,22 @@
|
||||
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||
* Given `onBlock`, each changed block also gets the read-only diff's Undo /
|
||||
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
|
||||
* panel turns it off while the editor holds unsaved text, because git acts on
|
||||
* the file on disk.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
@@ -139,6 +145,83 @@ const diffColours = (lang: string) =>
|
||||
{ decorations: (p) => p.decorations }
|
||||
);
|
||||
|
||||
/** A changed block by line (1-based; a count of 0 is a pure insertion or
|
||||
* removal): what the panel matches against git's own change blocks. */
|
||||
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
|
||||
export type BlockAction = "keep" | "undo";
|
||||
|
||||
/** A chunk side's first line and line count. `to` is the start of the line
|
||||
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
|
||||
const lineSpan = (doc: Text, from: number, to: number): [number, number] => {
|
||||
const first = doc.lineAt(Math.min(from, doc.length)).number;
|
||||
return [first, from >= to ? 0 : doc.lineAt(Math.min(to - 1, doc.length)).number - first + 1];
|
||||
};
|
||||
|
||||
class BlockBar extends WidgetType {
|
||||
constructor(
|
||||
readonly block: EditorBlock,
|
||||
readonly build: (b: EditorBlock) => HTMLElement
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(o: BlockBar) {
|
||||
const a = this.block;
|
||||
const b = o.block;
|
||||
return (
|
||||
a.oldFrom === b.oldFrom &&
|
||||
a.oldCount === b.oldCount &&
|
||||
a.newFrom === b.newFrom &&
|
||||
a.newCount === b.newCount
|
||||
);
|
||||
}
|
||||
toDOM() {
|
||||
return this.build(this.block);
|
||||
}
|
||||
}
|
||||
|
||||
const barsOn = StateEffect.define<boolean>();
|
||||
|
||||
/** One bar per chunk, under its last line (above a pure removal), rebuilt
|
||||
* whenever the chunks are — and dropped while `barsOn` is false. */
|
||||
function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
const decos = (doc: Text, chunks: readonly Chunk[]) => {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
for (const c of chunks) {
|
||||
const [oldFrom, oldCount] = lineSpan(orig, c.fromA, c.toA);
|
||||
const [newFrom, newCount] = lineSpan(doc, c.fromB, c.toB);
|
||||
const pos = newCount ? doc.line(newFrom + newCount - 1).to : Math.min(c.fromB, doc.length);
|
||||
out.add(
|
||||
pos,
|
||||
pos,
|
||||
Decoration.widget({
|
||||
block: true,
|
||||
side: newCount ? 1 : -1,
|
||||
widget: new BlockBar({ oldFrom, oldCount, newFrom, newCount }, build),
|
||||
})
|
||||
);
|
||||
}
|
||||
return out.finish();
|
||||
};
|
||||
return StateField.define<{ on: boolean; deco: DecorationSet }>({
|
||||
create: () => ({ on: false, deco: Decoration.none }),
|
||||
update(v, tr) {
|
||||
let on = v.on;
|
||||
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
|
||||
const chunks = getChunks(tr.state)?.chunks;
|
||||
if (on === v.on && chunks === getChunks(tr.startState)?.chunks) {
|
||||
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
|
||||
}
|
||||
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
|
||||
});
|
||||
}
|
||||
|
||||
const CHIP =
|
||||
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||
const UNDO = "border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
|
||||
|
||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||
const RED = "rgb(var(--status-danger) / 0.18)";
|
||||
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||
@@ -217,6 +300,7 @@ export default function DiffEditor({
|
||||
revertLabel,
|
||||
onChange,
|
||||
onSave,
|
||||
onBlock,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
@@ -231,11 +315,65 @@ export default function DiffEditor({
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
/** Keep / Undo one changed block in git; absent = no bars. */
|
||||
onBlock?: (action: BlockAction, block: EditorBlock) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
const cb = useRef({ onChange, onSave, onBlock, t });
|
||||
cb.current = { onChange, onSave, onBlock, t };
|
||||
|
||||
/** The diff view's review bar, as DOM: pinned to the visible right edge,
|
||||
* zero height so it never pushes lines (or a split view's alignment). */
|
||||
const buildBar = (block: EditorBlock) => {
|
||||
const tr = cb.current.t;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "flex h-0";
|
||||
const pin = wrap.appendChild(document.createElement("span"));
|
||||
pin.className = "sticky right-2 ml-auto h-0 w-0 self-start";
|
||||
const bar = pin.appendChild(document.createElement("span"));
|
||||
bar.dataset.testid = "lane-editor-block-bar";
|
||||
bar.className =
|
||||
"absolute right-0 top-0.5 z-10 flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card";
|
||||
const chip = (text: string, title: string, cls: string) => {
|
||||
const b = bar.appendChild(document.createElement("button"));
|
||||
b.type = "button";
|
||||
b.textContent = text;
|
||||
b.title = title;
|
||||
b.setAttribute("aria-label", title);
|
||||
b.className = `${CHIP} ${cls}`;
|
||||
return b;
|
||||
};
|
||||
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
|
||||
const keep = chip(
|
||||
tr("changes.stageHunkShort"),
|
||||
tr("changes.stageHunk"),
|
||||
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
|
||||
);
|
||||
// Two clicks, like every undo git cannot give back; disarms after 3 s.
|
||||
let timer = 0;
|
||||
const arm = (on: boolean) => {
|
||||
undo.dataset.armed = on ? "1" : "";
|
||||
undo.textContent = tr(on ? "changes.undoHunkShortConfirm" : "changes.undoHunkShort");
|
||||
const title = tr(on ? "changes.undoHunkConfirm" : "changes.undoHunk");
|
||||
undo.title = title;
|
||||
undo.setAttribute("aria-label", title);
|
||||
undo.className = `${CHIP} ${on ? UNDO_ARMED : UNDO}`;
|
||||
};
|
||||
undo.onclick = () => {
|
||||
window.clearTimeout(timer);
|
||||
if (undo.dataset.armed) {
|
||||
arm(false);
|
||||
cb.current.onBlock?.("undo", block);
|
||||
return;
|
||||
}
|
||||
arm(true);
|
||||
timer = window.setTimeout(() => arm(false), 3000);
|
||||
};
|
||||
keep.onclick = () => cb.current.onBlock?.("keep", block);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||
@@ -263,6 +401,7 @@ export default function DiffEditor({
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
}),
|
||||
blockBars(Text.of(original.split("\n")), buildBar),
|
||||
];
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
@@ -311,6 +450,11 @@ export default function DiffEditor({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const bars = !!onBlock;
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({ effects: barsOn.of(bars) });
|
||||
}, [bars]);
|
||||
|
||||
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
@@ -324,7 +468,9 @@ export default function DiffEditor({
|
||||
ref={host}
|
||||
data-testid="lane-file-editor"
|
||||
data-layout={layout}
|
||||
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
||||
data-bars={bars}
|
||||
// With the git bars up, a block's local Revert would be a second Undo.
|
||||
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* file — one file, as a diff or as its whole content (issue #35); a
|
||||
* working file is always the editor (`DiffEditor`, diffed
|
||||
* against HEAD / the branch's merge base, unified or side by
|
||||
* side): Changes folds the unchanged lines away, Whole file
|
||||
* side, each block with the diff's Undo / Keep while nothing
|
||||
* is unsaved): Changes folds the unchanged lines away, Whole file
|
||||
* shows them; any other file opens on its read-only diff; a
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
@@ -30,10 +31,20 @@
|
||||
* before the server is told `confirm: true` — it is the one action in this
|
||||
* panel git cannot undo.
|
||||
*
|
||||
* During a merge (issue #45) a banner offers **Complete merge** (only once no
|
||||
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
|
||||
* conflicted files get their own group with Keep mine / Take theirs / Mark
|
||||
* resolved instead of stage — staging one blind would mark it resolved.
|
||||
* During a merge, cherry-pick or revert (issue #45) a banner offers
|
||||
* **Complete** (only once no file is conflicted) and **Abort** (two clicks,
|
||||
* `confirm: true`), and conflicted files get their own group with Keep mine /
|
||||
* Take theirs / Mark resolved instead of stage — staging one blind would mark
|
||||
* it resolved.
|
||||
*
|
||||
* History rewrites through its commit menu: Revert (any commit), Undo commit
|
||||
* (the top one; the server refuses a pushed one) and, while another branch's
|
||||
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
|
||||
* folds the staged changes into the last commit. All four ask first
|
||||
* (`confirmDialog`).
|
||||
*
|
||||
* "vs base" compares against the lane's base, else the repo's default branch
|
||||
* (`git.base`), and is hidden when there is none or the lane IS on it.
|
||||
*
|
||||
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||||
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||||
@@ -45,7 +56,7 @@
|
||||
* reloading the browser tab asks the browser's own question, and
|
||||
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
|
||||
* switching its tab, or moving it to another workspace or session. Two-click
|
||||
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
|
||||
* actions (discard, a hunk's Undo, Abort) disarm after 3 s (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -76,16 +87,18 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||
import type { BlockAction, EditorBlock } from "./DiffEditor";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
changeBlocks,
|
||||
langFor,
|
||||
type DiffLayout,
|
||||
type LaneDiffChange,
|
||||
@@ -350,6 +363,22 @@ function IconButton({
|
||||
const HUNK_CHIP =
|
||||
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
/** The git change block an editor block is, by its line span on both sides;
|
||||
* none when the editor's diff and git's split the change differently. */
|
||||
export function gitChangeFor(f: LaneChangeFile, b: EditorBlock): LaneDiffChange | undefined {
|
||||
return (f.hunks ?? []).flatMap(changeBlocks).find((c) => {
|
||||
const lines = c.hunk.lines.slice(c.firstLine, c.lastLine + 1);
|
||||
const del = lines.filter((l) => l.type === "del");
|
||||
const add = lines.filter((l) => l.type === "add");
|
||||
return (
|
||||
del.length === b.oldCount &&
|
||||
add.length === b.newCount &&
|
||||
(!del.length || del[0]!.oldLine === b.oldFrom) &&
|
||||
(!add.length || add[0]!.newLine === b.newFrom)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
|
||||
|
||||
/** A work-range file's stage / unstage / discard buttons — the overview row's
|
||||
@@ -401,20 +430,32 @@ function FileActions({
|
||||
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onClick={() => {
|
||||
// Two clicks, always: the first arms the row, the second is the
|
||||
// only one that reaches the server (which itself refuses the
|
||||
// call without `confirm: true`).
|
||||
if (armed) onAct("discard", f.path, true);
|
||||
else onArm(f.path);
|
||||
}}
|
||||
>
|
||||
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
|
||||
</IconButton>
|
||||
{/* Two clicks, always: the first arms the row, the second is the only
|
||||
one that reaches the server (which itself refuses the call without
|
||||
`confirm: true`). Armed, the icon becomes a red word, so the second
|
||||
click is asked for where the eye already is. */}
|
||||
{armed ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-confirm"
|
||||
aria-label={t("changes.discardConfirm")}
|
||||
title={t("changes.discardConfirm")}
|
||||
disabled={busy}
|
||||
onClick={() => onAct("discard", f.path, true)}
|
||||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
||||
>
|
||||
{t("changes.discardShortConfirm")}
|
||||
</button>
|
||||
) : (
|
||||
<IconButton
|
||||
label={t("changes.discard")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onClick={() => onArm(f.path)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -694,6 +735,8 @@ export function LaneChangesPanel({
|
||||
// One read of the working copy, shared by the header and every write below:
|
||||
// a stage, a discard or a commit changes the counts the header shows.
|
||||
const [git, refreshGit] = useLaneGitFacts(laneId, session);
|
||||
// A merge, cherry-pick or revert is open: no new one starts from history.
|
||||
const opOpen = !!(git?.available && git.merging);
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
@@ -823,6 +866,12 @@ export function LaneChangesPanel({
|
||||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||||
const pendingScroll = useRef<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// History of another branch (where Cherry-pick finds its commits); null =
|
||||
// this branch's own.
|
||||
const [historyRef, setHistoryRef] = useState<string | null>(null);
|
||||
const [historyBranches, setHistoryBranches] = useState<string[]>([]);
|
||||
const [suggesting, setSuggesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -898,11 +947,18 @@ export function LaneChangesPanel({
|
||||
if (mode !== "history") return;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: historyLimit })
|
||||
.gitLog(target, { limit: historyLimit, ...(historyRef ? { ref: historyRef } : {}) })
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
}, [mode, laneId, reread, historyLimit]);
|
||||
}, [mode, laneId, reread, historyLimit, historyRef]);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
void api.lanes
|
||||
.gitBranches(target)
|
||||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
||||
.catch(() => setHistoryBranches([]));
|
||||
}, [mode, laneId, reread]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||||
// stays across the switch, so the mode alone never changes.
|
||||
@@ -1038,6 +1094,8 @@ export function LaneChangesPanel({
|
||||
setConfirmDiscard(null);
|
||||
// Same for a half-typed commit message: it describes the OTHER lane's work.
|
||||
setMessage("");
|
||||
setAmend(false);
|
||||
setHistoryRef(null);
|
||||
returnTo.current = null;
|
||||
pendingScroll.current = null;
|
||||
}, [laneId]);
|
||||
@@ -1177,6 +1235,69 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||||
|
||||
// Revert / cherry-pick / undo from the history's commit menu. A conflict is
|
||||
// an operation that STARTED: the overview is where it gets resolved.
|
||||
const historyAct = async (action: "cherry-pick" | "revert" | "undo-commit", c: LaneCommit) => {
|
||||
// Every one of the three rewrites this branch's history, so each asks.
|
||||
const branch = git?.available ? git.branch : "HEAD";
|
||||
const ask =
|
||||
action === "undo-commit"
|
||||
? {
|
||||
title: t("changes.undoCommitTitle"),
|
||||
message: t("changes.undoCommitBody", { short: c.short, subject: c.subject }),
|
||||
confirmLabel: t("changes.undoCommitConfirm"),
|
||||
}
|
||||
: action === "revert"
|
||||
? {
|
||||
title: t("changes.revertCommit"),
|
||||
message: t("changes.revertConfirm", { short: c.short, subject: c.subject, branch }),
|
||||
confirmLabel: t("changes.revertCommit"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("changes.cherryPick", { branch }),
|
||||
message: t("changes.cherryPickConfirm", {
|
||||
short: c.short,
|
||||
subject: c.subject,
|
||||
from: historyRef,
|
||||
branch,
|
||||
}),
|
||||
confirmLabel: t("changes.cherryPick", { branch }),
|
||||
destructive: false,
|
||||
};
|
||||
if (!(await confirmDialog(ask))) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.gitAction(
|
||||
target,
|
||||
action,
|
||||
// The commit the dialog named: the server refuses an undo once HEAD
|
||||
// is another one (ESTALE) — an agent may have committed meanwhile.
|
||||
action === "undo-commit" ? { confirm: true, sha: c.sha } : { sha: c.sha }
|
||||
);
|
||||
if (laneNow.current !== lane) return;
|
||||
if (r.conflicts?.length) {
|
||||
toast.error(t("changes.opConflicts", { op: action, count: r.conflicts.length }));
|
||||
} else if (action !== "undo-commit") {
|
||||
toast.success(t("changes.opDone", { op: action }));
|
||||
}
|
||||
// Conflicts and an undone commit's staged changes are both work-range
|
||||
// facts; a clean pick or revert is best seen in this branch's history.
|
||||
setHistoryRef(null);
|
||||
if (action === "undo-commit" || r.conflicts?.length) {
|
||||
setRange("work");
|
||||
setMode("overview");
|
||||
}
|
||||
void rereadAll();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||||
const commitCtx = useContextMenu<LaneCommit>();
|
||||
@@ -1305,6 +1426,45 @@ export function LaneChangesPanel({
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||||
{t("changes.copySubject")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{historyRef ? (
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-cherry-pick"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("cherry-pick", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-revert"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("revert", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.revertCommit")}
|
||||
</MenuItem>
|
||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("undo-commit", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
@@ -1333,6 +1493,14 @@ export function LaneChangesPanel({
|
||||
setArmedHunk(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
// Undone under the open editor: it shows the file as it is now (the
|
||||
// bars only exist while nothing is unsaved, so no draft is lost).
|
||||
if (action === "discard-hunk" && text?.path === path) {
|
||||
const r = await api.lanes.gitFile(target, path, "work");
|
||||
if (laneNow.current !== lane || !r.available) return;
|
||||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||||
setDraft(r.lines.join("\n"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -1343,8 +1511,10 @@ export function LaneChangesPanel({
|
||||
// Stage / Undo on each change block of a file with unstaged changes. The diff is
|
||||
// HEAD → working tree, so a staged-only file gets none: undoing its tree
|
||||
// would leave the index holding the change.
|
||||
const hunkActable = (f: LaneChangeFile) =>
|
||||
range === "work" && f.unstaged && !f.untracked && !f.binary;
|
||||
const hunkActions = (f: LaneChangeFile) =>
|
||||
range === "work" && f.unstaged && !f.untracked && !f.binary
|
||||
hunkActable(f)
|
||||
? (change: LaneDiffChange) => {
|
||||
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
|
||||
const armed = armedHunk === key;
|
||||
@@ -1382,6 +1552,19 @@ export function LaneChangesPanel({
|
||||
: undefined;
|
||||
|
||||
const commitStaged = async () => {
|
||||
// Amend rewrites the last commit: asked, like every history rewrite.
|
||||
if (
|
||||
amend &&
|
||||
!(await confirmDialog({
|
||||
title: t("changes.amendTitle"),
|
||||
message: t("changes.amendConfirm", {
|
||||
subject: git?.available ? git.subject : "",
|
||||
}),
|
||||
confirmLabel: t("changes.amend"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -1389,9 +1572,20 @@ export function LaneChangesPanel({
|
||||
// `only: "staged"` — the whole point of staging file by file is that the
|
||||
// commit takes the index as it stands, with no `add -A` sweeping the
|
||||
// rest of the tree in behind it.
|
||||
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
|
||||
if (amend) {
|
||||
// No message keeps the old one (`--no-edit`).
|
||||
// `sha`: the commit the dialog named (ESTALE if HEAD moved since).
|
||||
const head = git?.available ? git.head : null;
|
||||
await api.lanes.gitAction(target, "amend", {
|
||||
...(message.trim() ? { message: message.trim() } : {}),
|
||||
...(head ? { sha: head } : {}),
|
||||
});
|
||||
} else {
|
||||
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
|
||||
}
|
||||
if (laneNow.current !== lane) return;
|
||||
setMessage("");
|
||||
setAmend(false);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -1453,9 +1647,17 @@ export function LaneChangesPanel({
|
||||
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
|
||||
: [];
|
||||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||||
// "vs base" needs a base, and means nothing on the base branch itself. An
|
||||
// older server reports no `base` at all: keep the toggle as it was.
|
||||
const base = git?.available ? git.base : undefined;
|
||||
const showBranchRange =
|
||||
base === undefined || (base !== null && !(git?.available && git.branch === base));
|
||||
|
||||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||||
// so a later reappearance starts from the first click again.
|
||||
useEffect(() => {
|
||||
if (!showBranchRange && range === "branch") setRange("work");
|
||||
}, [showBranchRange, range]);
|
||||
useEffect(() => {
|
||||
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
|
||||
setConfirmDiscard(null);
|
||||
@@ -1622,35 +1824,44 @@ export function LaneChangesPanel({
|
||||
|
||||
{mode !== "history" && mode !== "browse" && (
|
||||
<div className="contents">
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(["work", "branch"] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
aria-pressed={range === r}
|
||||
onClick={() => {
|
||||
setRange(r);
|
||||
setSha(null);
|
||||
}}
|
||||
title={t(`changes.range.${r}Hint`)}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`changes.range.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
{range === "commit" && sha && (
|
||||
<span
|
||||
data-testid="lane-changes-commit-chip"
|
||||
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{sha.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* With nothing to compare against, "Uncommitted" alone would be a
|
||||
toggle with one side: none is drawn, unless a commit is open —
|
||||
then Uncommitted is the way back. */}
|
||||
{(showBranchRange || (range === "commit" && sha)) && (
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(showBranchRange ? (["work", "branch"] as const) : (["work"] as const)).map(
|
||||
(r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
aria-pressed={range === r}
|
||||
onClick={() => {
|
||||
setRange(r);
|
||||
setSha(null);
|
||||
}}
|
||||
title={t(`changes.range.${r}Hint`, {
|
||||
base: base || t("changes.range.baseBranch"),
|
||||
})}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`changes.range.${r}`, { base: base || t("changes.range.baseBranch") })}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{range === "commit" && sha && (
|
||||
<span
|
||||
data-testid="lane-changes-commit-chip"
|
||||
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{sha.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mode === "file" || mode === "review") && (
|
||||
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
|
||||
@@ -1758,8 +1969,8 @@ export function LaneChangesPanel({
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
{git.conflicts
|
||||
? t("changes.mergeBanner", { count: git.conflicts })
|
||||
: t("changes.mergeReady")}
|
||||
? t("changes.mergeBanner", { count: git.conflicts, op: git.operation ?? "merge" })
|
||||
: t("changes.mergeReady", { op: git.operation ?? "merge" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1767,7 +1978,7 @@ export function LaneChangesPanel({
|
||||
onClick={() => void mergeAct("merge-commit")}
|
||||
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.mergeCommit")}
|
||||
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1778,7 +1989,9 @@ export function LaneChangesPanel({
|
||||
}
|
||||
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
|
||||
>
|
||||
{armedAbort ? t("changes.mergeAbortConfirm") : t("changes.mergeAbort")}
|
||||
{armedAbort
|
||||
? t("changes.mergeAbortConfirm", { op: git.operation ?? "merge" })
|
||||
: t("changes.mergeAbort", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1796,6 +2009,29 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history">
|
||||
{historyBranches.length > 1 && (
|
||||
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
|
||||
<span className="shrink-0">{t("changes.historyOf")}</span>
|
||||
<select
|
||||
data-testid="lane-changes-history-ref"
|
||||
value={historyRef ?? ""}
|
||||
onChange={(e) => {
|
||||
setHistoryRef(e.target.value || null);
|
||||
setCommits(null);
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="">{git?.available ? git.branch : "HEAD"}</option>
|
||||
{historyBranches
|
||||
.filter((b) => !(git?.available && b === git.branch))
|
||||
.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
)}
|
||||
{(() => {
|
||||
const list = commits ?? [];
|
||||
const graph = commitGraph(list);
|
||||
@@ -1875,10 +2111,16 @@ export function LaneChangesPanel({
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
|
||||
if (isSubmitEnter(e) && (message.trim() || amend) && !busy) {
|
||||
void commitStaged();
|
||||
}
|
||||
}}
|
||||
placeholder={t("changes.commitPlaceholder")}
|
||||
aria-label={t("changes.commitPlaceholder")}
|
||||
placeholder={t(
|
||||
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
|
||||
)}
|
||||
aria-label={t(
|
||||
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
|
||||
)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
@@ -1896,13 +2138,27 @@ export function LaneChangesPanel({
|
||||
<Sparkles className="h-3 w-3" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<label
|
||||
title={t("changes.amendHint")}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="lane-changes-amend"
|
||||
checked={amend}
|
||||
onChange={(e) => setAmend(e.target.checked)}
|
||||
/>
|
||||
{t("changes.amend")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || suggesting || !message.trim()}
|
||||
disabled={busy || suggesting || !(message.trim() || amend)}
|
||||
onClick={() => void commitStaged()}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.commitStaged", { count: stagedCount })}
|
||||
{amend
|
||||
? t("changes.amend")
|
||||
: t("changes.commitStaged", { count: stagedCount })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -2184,6 +2440,21 @@ export function LaneChangesPanel({
|
||||
onSave={() => {
|
||||
if (!busy) void saveDraft();
|
||||
}}
|
||||
// The same Undo / Keep as the diff, on the file on
|
||||
// disk — so only while the editor holds no unsaved text.
|
||||
onBlock={
|
||||
current && hunkActable(current) && !dirty && !busy
|
||||
? (action: BlockAction, b: EditorBlock) => {
|
||||
const change = gitChangeFor(current, b);
|
||||
if (!change) return setError(t("changes.blockNotInDiff"));
|
||||
void actHunk(
|
||||
action === "keep" ? "stage-hunk" : "discard-hunk",
|
||||
current.path,
|
||||
change
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
|
||||
* asks through the themed confirm; the rows wrap in a narrow panel.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
|
||||
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -234,6 +234,45 @@ export function LaneGitBar({
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
// Writes that leave this machine (push, publish) or delete a ref ask
|
||||
// first. Asked HERE, at the click — never inside `run` — so the retry a git
|
||||
// login sends is not asked twice.
|
||||
const confirmRun = async (
|
||||
opts: { title: string; message: string; destructive?: boolean },
|
||||
action: Action,
|
||||
body?: RunBody
|
||||
) => {
|
||||
if (await confirmDialog({ destructive: false, confirmLabel: opts.title, ...opts })) {
|
||||
void run(action, body);
|
||||
}
|
||||
};
|
||||
const push = () =>
|
||||
confirmRun(
|
||||
git.upstream
|
||||
? {
|
||||
title: t("gitBar.push"),
|
||||
message: t("gitBar.pushConfirm", {
|
||||
count: git.ahead ?? 0,
|
||||
branch: git.branch,
|
||||
upstream: git.upstream,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
title: t("gitBar.publishBranch"),
|
||||
message: t("gitBar.publishBranchConfirm", { branch: git.branch }),
|
||||
},
|
||||
"push"
|
||||
);
|
||||
const publish = () =>
|
||||
confirmRun(
|
||||
{
|
||||
title: t("gitBar.publishApply"),
|
||||
message: t("gitBar.publishConfirm", { branch: git.branch, url: url.trim() }),
|
||||
},
|
||||
"publish",
|
||||
{ url: url.trim() }
|
||||
);
|
||||
|
||||
const merge = async (branch: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("gitBar.merge"),
|
||||
@@ -322,7 +361,11 @@ export function LaneGitBar({
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy || merging}
|
||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: t("gitBar.pullHint")
|
||||
}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
@@ -338,7 +381,7 @@ export function LaneGitBar({
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
onClick={() => void push()}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
@@ -362,7 +405,7 @@ export function LaneGitBar({
|
||||
// beats a red box after it.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging")
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
: t("gitBar.switchHint")
|
||||
@@ -434,7 +477,17 @@ export function LaneGitBar({
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
title={t("gitBar.branchDeleteHint")}
|
||||
onClick={() => void run("branch-delete", { branch: picked })}
|
||||
onClick={() =>
|
||||
void confirmRun(
|
||||
{
|
||||
title: t("gitBar.branchDelete"),
|
||||
message: t("gitBar.branchDeleteConfirm", { branch: picked }),
|
||||
destructive: true,
|
||||
},
|
||||
"branch-delete",
|
||||
{ branch: picked }
|
||||
)
|
||||
}
|
||||
/>
|
||||
{dirty > 0 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
@@ -474,7 +527,7 @@ export function LaneGitBar({
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() });
|
||||
if (isSubmitEnter(e) && url.trim()) void publish();
|
||||
}}
|
||||
// The server decides which URLs are acceptable (https without a
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
@@ -488,7 +541,7 @@ export function LaneGitBar({
|
||||
busy={busy === "publish"}
|
||||
disabled={!url.trim()}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => void run("publish", { url: url.trim() })}
|
||||
onClick={() => void publish()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||||
* `ConsoleTabs.tsx`). Inside
|
||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||||
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* by default; its last row, **Open terminal**, is no AI — it starts a plain
|
||||
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -101,6 +103,9 @@ type HomeProps = Parameters<typeof HomeBody>[0];
|
||||
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
||||
* model, an open folder form, a late upload's answer) belongs to ONE
|
||||
* workspace, and the page swaps `lane` without unmounting. */
|
||||
/** The AI menu row that opens a terminal; no catalog id can collide (ids are slugs). */
|
||||
const OPEN_TERMINAL = "__terminal__";
|
||||
|
||||
export function WorkspaceHome(props: HomeProps) {
|
||||
return <HomeBody key={props.lane.id} {...props} />;
|
||||
}
|
||||
@@ -311,6 +316,24 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
|
||||
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||
// at once — the same start the session view's tab bar `+` makes.
|
||||
const openTerminal = async () => {
|
||||
if (startingRef.current) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const runId = await startInFreeTab(lane.id, { shell: true });
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const text = promptWithImages(prompt, images);
|
||||
const start = async () => {
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
@@ -518,19 +541,29 @@ function HomeBody({
|
||||
label={t("home.ai")}
|
||||
value={tool}
|
||||
onChange={(v) => {
|
||||
// Not a tool: an action. The AI chosen for the prompt stays.
|
||||
if (v === OPEN_TERMINAL) return void openTerminal();
|
||||
setTool(v);
|
||||
setBypass(false);
|
||||
}}
|
||||
options={toolOptions.map((id) => ({
|
||||
value: id,
|
||||
label: aiTools.label(id),
|
||||
icon: (
|
||||
<AiToolIcon
|
||||
tool={{ id, label: aiTools.label(id) }}
|
||||
className="h-4 w-4 !p-0.5"
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
options={[
|
||||
...toolOptions.map((id) => ({
|
||||
value: id,
|
||||
label: aiTools.label(id),
|
||||
icon: (
|
||||
<AiToolIcon
|
||||
tool={{ id, label: aiTools.label(id) }}
|
||||
className="h-4 w-4 !p-0.5"
|
||||
/>
|
||||
),
|
||||
})),
|
||||
{
|
||||
value: OPEN_TERMINAL,
|
||||
label: t("home.openTerminal"),
|
||||
icon: <SquareTerminal className="h-4 w-4 text-fg-muted" />,
|
||||
divider: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
|
||||
* session has no row yet — a bare tab before its first prompt — is listed as
|
||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||
* which never has a session) as **Terminal** — and under the sessions the last
|
||||
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
||||
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
|
||||
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
|
||||
* which never has a session) as **Terminal** while it is open — a closed one
|
||||
* leaves no row; Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
@@ -32,24 +30,20 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
|
||||
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
|
||||
export const SESSION_LIMIT = 6;
|
||||
/** Closed Terminal tabs listed under a workspace's sessions. */
|
||||
export const CLOSED_TERMINALS = 3;
|
||||
|
||||
/**
|
||||
* The live pane holding a session: the run the hooks bound it to
|
||||
@@ -278,43 +272,6 @@ export function WorkspaceSessionList({
|
||||
3000
|
||||
);
|
||||
const refreshKey = `${hookKey}:${version}`;
|
||||
// A Terminal tab closing is when one joins the history.
|
||||
const shellKey = liveShells.map((r) => r.id).join(",");
|
||||
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
||||
useEffect(() => {
|
||||
if (hidden) return;
|
||||
let live = true;
|
||||
api.run
|
||||
// Live ones come back too (isLive): ask for enough to fill the list anyway.
|
||||
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
|
||||
.then(
|
||||
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id, shellKey, hidden]);
|
||||
const reopenShell = async (split: boolean) => {
|
||||
try {
|
||||
const runId = await startInFreeTab(lane.id, { shell: true });
|
||||
navigate(
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
|
||||
split ? { state: { split: true } } : undefined
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const closedCtx = useContextMenu<RunHistoryItem>();
|
||||
const forgetShell = async (r: RunHistoryItem) => {
|
||||
try {
|
||||
await api.run.forgetHistory(r.id);
|
||||
setClosedShells((all) => all.filter((x) => x.id !== r.id));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
@@ -440,9 +397,7 @@ export function WorkspaceSessionList({
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
// Not during a search, like the live ones: they have no name to match.
|
||||
const closed = q ? [] : closedShells;
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
@@ -599,42 +554,6 @@ export function WorkspaceSessionList({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{closed.map((r) => (
|
||||
<li key={r.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-terminal-closed-${r.id}`}
|
||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
||||
onContextMenu={(e) => closedCtx.open(e, r)}
|
||||
title={t("tree.closedTerminal")}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full border border-fg-muted"
|
||||
/>
|
||||
<span className="sr-only">{t("tree.state.idle")}</span>
|
||||
<span className="truncate">{t("tabs.terminal")}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px]">
|
||||
{timeAgo(r.ended_at ?? r.started_at, true)}
|
||||
</span>
|
||||
<SquareTerminal
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-terminal-forget-${r.id}`}
|
||||
onClick={() => void forgetShell(r)}
|
||||
title={t("tree.forgetTerminal")}
|
||||
aria-label={t("tree.forgetTerminal")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{more > 0 && !q && (
|
||||
<button
|
||||
@@ -658,42 +577,6 @@ export function WorkspaceSessionList({
|
||||
onClose={ctx.close}
|
||||
/>
|
||||
)}
|
||||
{closedCtx.menu && (
|
||||
<ContextMenu
|
||||
x={closedCtx.menu.x}
|
||||
y={closedCtx.menu.y}
|
||||
onClose={closedCtx.close}
|
||||
testId={`terminal-menu-${closedCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
closedCtx.close();
|
||||
void reopenShell(false);
|
||||
}}
|
||||
>
|
||||
{t("tree.reopenTerminal")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
closedCtx.close();
|
||||
void reopenShell(true);
|
||||
}}
|
||||
>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const item = closedCtx.menu!.item;
|
||||
closedCtx.close();
|
||||
void forgetShell(item);
|
||||
}}
|
||||
>
|
||||
{t("tree.forgetTerminal")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{sessionCtx.menu && (
|
||||
<SessionMenu
|
||||
x={sessionCtx.menu.x}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -119,6 +119,13 @@ function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> =
|
||||
);
|
||||
}
|
||||
|
||||
/** The modal opens on Repo; worktree tests switch to Worktree first. */
|
||||
function renderWorktree(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||
const r = renderModal(over);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Worktree" }));
|
||||
return r;
|
||||
}
|
||||
|
||||
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
|
||||
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
|
||||
* then click the field to reclaim focus before typing. */
|
||||
@@ -127,7 +134,7 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
|
||||
await user.click(el);
|
||||
}
|
||||
|
||||
/** Fills the default "Worktree" mode's form up through a resolved branch
|
||||
/** Fills the "Worktree" mode's form up through a resolved branch
|
||||
* list, title, and new-branch name - everything Add lane needs to enable. */
|
||||
async function fillWorktreeForm(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
@@ -173,7 +180,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
|
||||
it("lists the repo's existing worktrees once the path resolves", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -188,7 +195,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
it("checks an existing branch out, sending no base", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -211,7 +218,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
|
||||
it("greys out a branch another worktree already holds", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -229,7 +236,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
it("lets the server name a new branch when the field is left empty", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -253,11 +260,11 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — worktree mode (default)", () => {
|
||||
describe("AddLaneModal — worktree mode", () => {
|
||||
// The branch NAME is no longer part of this list: an empty one means "let
|
||||
// the server name it" (issue #34).
|
||||
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -267,7 +274,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -284,7 +291,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
|
||||
it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
|
||||
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -303,7 +310,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -328,7 +335,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose, onAdded });
|
||||
renderWorktree({ onClose, onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
|
||||
@@ -360,7 +367,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
renderWorktree({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "New feature" });
|
||||
@@ -385,7 +392,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
renderWorktree({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user);
|
||||
@@ -410,7 +417,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
renderWorktree({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "demo2" });
|
||||
@@ -431,7 +438,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
renderWorktree({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "demo3" });
|
||||
@@ -463,7 +470,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
renderWorktree({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "demo4" });
|
||||
@@ -477,6 +484,12 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
it("opens in Repo mode by default, with no branch fields", () => {
|
||||
renderModal();
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the branch fields and enables confirm on a path alone", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
@@ -727,7 +740,7 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
|
||||
it("jumps to Repo mode from the branch choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||
* count as edited in place (blue) rather than added or removed whole, and the
|
||||
* editor's own diff classes on a real mount.
|
||||
* editor's own diff classes on a real mount, and the per-block Undo / Keep bar.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
@@ -90,4 +90,55 @@ describe("DiffEditor", () => {
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
|
||||
it("puts an Undo / Keep bar under each block, by line, in place of Revert", async () => {
|
||||
const onBlock = vi.fn();
|
||||
const props = {
|
||||
value: "a\nB\nc\nnew\ne",
|
||||
original: "a\nb\nc\nd\ne",
|
||||
lang: "plain",
|
||||
label: "Edit",
|
||||
revertLabel: "Revert",
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(<DiffEditor {...props} onBlock={onBlock} />);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
|
||||
);
|
||||
expect(container.querySelector('[data-testid="lane-file-editor"]')).toHaveAttribute(
|
||||
"data-bars",
|
||||
"true"
|
||||
);
|
||||
const [first, second] = [
|
||||
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
|
||||
];
|
||||
fireEvent.click(second!.querySelectorAll("button")[1]!);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("keep", {
|
||||
oldFrom: 4,
|
||||
oldCount: 1,
|
||||
newFrom: 4,
|
||||
newCount: 1,
|
||||
});
|
||||
// Undo asks twice.
|
||||
const firstUndo = first!.querySelector("button")!;
|
||||
fireEvent.click(firstUndo);
|
||||
expect(onBlock).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(firstUndo);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("undo", {
|
||||
oldFrom: 2,
|
||||
oldCount: 1,
|
||||
newFrom: 2,
|
||||
newCount: 1,
|
||||
});
|
||||
// Unsaved text (no onBlock): no bars, Revert back.
|
||||
rerender(<DiffEditor {...props} />);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="lane-editor-block-bar"]')).toBeNull()
|
||||
);
|
||||
expect(container.querySelector('[data-testid="lane-file-editor"]')).toHaveAttribute(
|
||||
"data-bars",
|
||||
"false"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { confirmDiscard } from "../../../lib/confirm";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
@@ -153,6 +153,14 @@ beforeEach(() => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.gitBranches)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["feat/thing", "other"],
|
||||
current: "feat/thing",
|
||||
worktrees: [],
|
||||
});
|
||||
vi.mocked(api.lanes.gitLs)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -255,7 +263,8 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "vs base" }));
|
||||
// No git facts (an older server): the base has no name to show.
|
||||
await user.click(screen.getByRole("button", { name: "Compared to base branch" }));
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||
});
|
||||
|
||||
@@ -739,6 +748,129 @@ describe("LaneChangesPanel", () => {
|
||||
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
|
||||
});
|
||||
|
||||
describe("history rewrites, amend and vs base", () => {
|
||||
const facts = (over: Record<string, unknown> = {}) => ({
|
||||
available: true as const,
|
||||
branch: "feat/thing",
|
||||
head: "aaaaaaa",
|
||||
subject: "feat: the commit",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
base: "main",
|
||||
...over,
|
||||
});
|
||||
const commitMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const row = await within(await screen.findByTestId("lane-changes-history")).findByText(
|
||||
"feat: the commit"
|
||||
);
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
return screen.getByTestId("lane-changes-commit-menu");
|
||||
};
|
||||
const withParent = () =>
|
||||
vi.mocked(api.lanes.gitLog).mockResolvedValue({
|
||||
available: true,
|
||||
commits: [
|
||||
{
|
||||
sha: "a".repeat(40),
|
||||
short: "aaaaaaa",
|
||||
subject: "feat: the commit",
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
parents: ["b".repeat(40)],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("reverts a commit and undoes the top one from the history menu", async () => {
|
||||
withParent();
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
|
||||
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
|
||||
|
||||
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "undo-commit", {
|
||||
confirm: true,
|
||||
sha: "a".repeat(40),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("cherry-picks only from another branch's history", async () => {
|
||||
withParent();
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(within(await commitMenu(user)).queryByText(/Cherry-pick/)).toBeNull();
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
|
||||
);
|
||||
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
|
||||
});
|
||||
|
||||
it("asks before a revert and an amend, and a No sends nothing", async () => {
|
||||
withParent();
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-amend"));
|
||||
await user.click(screen.getByRole("button", { name: "Amend" }));
|
||||
expect(confirmDialog).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("amends the last commit, keeping its message when none is typed", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTestId("lane-changes-amend"));
|
||||
await user.click(screen.getByRole("button", { name: "Amend" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", { sha: "aaaaaaa" })
|
||||
);
|
||||
});
|
||||
|
||||
it("names the base in full, and on the base branch itself shows no toggle at all", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||
const { unmount } = render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByRole("button", { name: "Compared to main" })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(facts({ branch: "main" }));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-git");
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
expect(screen.queryByRole("button", { name: "Compared to main" })).toBeNull();
|
||||
// A lone "Uncommitted" would be a toggle with nothing to toggle to.
|
||||
expect(screen.queryByRole("button", { name: "Uncommitted" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
|
||||
@@ -1647,3 +1779,44 @@ describe("a merge in progress (issue #45)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gitChangeFor", () => {
|
||||
// One hunk, two change blocks three lines apart: an edit and a removal.
|
||||
const file = {
|
||||
path: "a.txt",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
added: 1,
|
||||
deleted: 2,
|
||||
binary: false,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,6 +1,5 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
|
||||
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
|
||||
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
|
||||
{ type: "ctx" as const, oldLine: 4, newLine: 4, text: "d" },
|
||||
{ type: "del" as const, oldLine: 5, newLine: null, text: "e" },
|
||||
{ type: "ctx" as const, oldLine: 6, newLine: 5, text: "f" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("finds git's block by its line span on both sides", () => {
|
||||
expect(gitChangeFor(file, { oldFrom: 1, oldCount: 1, newFrom: 1, newCount: 1 })?.block).toBe(0);
|
||||
// A pure removal: matched on the old side alone.
|
||||
expect(gitChangeFor(file, { oldFrom: 5, oldCount: 1, newFrom: 5, newCount: 0 })?.block).toBe(1);
|
||||
});
|
||||
|
||||
it("finds none when the editor split the change differently", () => {
|
||||
expect(
|
||||
gitChangeFor(file, { oldFrom: 1, oldCount: 2, newFrom: 1, newCount: 1 })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
// A merge asks through the themed confirm (its host lives in App): yes.
|
||||
@@ -129,6 +130,40 @@ describe("LaneGitBar", () => {
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks before every outward or ref-deleting write, and a No sends nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
const { rerender } = render(
|
||||
<LaneGitBar laneId={3} git={facts({ ahead: 2 })} onChanged={vi.fn()} />
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(confirmDialog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("2 commit") })
|
||||
);
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
// A Yes goes through.
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
|
||||
vi.mocked(api.lanes.gitAction).mockClear();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
rerender(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
||||
await user.type(screen.getByLabelText(/Remote URL/), "git@host:o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||
expect(confirmDialog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("git@host:o/r.git") })
|
||||
);
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -338,6 +338,21 @@ describe("WorkspaceHome", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a terminal tab at once from the AI menu, without a prompt", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-old");
|
||||
const ai = screen.getByTestId("workspace-home-tool");
|
||||
await user.click(within(ai).getByRole("button"));
|
||||
await user.click(screen.getByRole("option", { name: /Open terminal/ }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("where").textContent).toMatch(/^\/workspace\?lane=1&run=/)
|
||||
);
|
||||
// The AI picked for the next prompt is untouched.
|
||||
expect(within(ai).getByRole("button")).not.toHaveTextContent(/terminal/i);
|
||||
});
|
||||
|
||||
it("says so when the server finds all ten sessions in use", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||
|
||||
@@ -93,8 +93,9 @@
|
||||
"changes.noMatches": "No tracked file matches.",
|
||||
"changes.openFile": "Open {{path}}",
|
||||
"changes.quickOpen": "Find a file by path…",
|
||||
"changes.range.branch": "vs base",
|
||||
"changes.range.branchHint": "base...HEAD — everything this workspace's branch adds on top of its base",
|
||||
"changes.range.branch": "Compared to {{base}}",
|
||||
"changes.range.baseBranch": "base branch",
|
||||
"changes.range.branchHint": "{{base}}...HEAD — everything this branch adds on top of {{base}}",
|
||||
"changes.range.work": "Uncommitted",
|
||||
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
||||
"changes.refresh": "Refresh",
|
||||
@@ -244,6 +245,7 @@
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.revertBlock": "Revert",
|
||||
"changes.blockNotInDiff": "That change is no longer in git's diff; refresh and retry",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.preview": "Preview",
|
||||
"changes.raw": "Raw",
|
||||
@@ -269,14 +271,35 @@
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this workspace change now — if an agent is working in this workspace, let it finish first.",
|
||||
"gitBar.pushConfirm": "Push {{count}} commit(s) from {{branch}} to {{upstream}}? Anyone with access to the remote will see them, and a push cannot be taken back from here.",
|
||||
"gitBar.publishBranchConfirm": "Publish branch {{branch}} to the remote? Anyone with access to the remote will see it.",
|
||||
"gitBar.publishConfirm": "Add {{url}} as this repo's remote and push {{branch}} to it? Anyone with access to that remote will see the code.",
|
||||
"gitBar.branchDeleteConfirm": "Delete the local branch {{branch}}? Git refuses if it holds commits merged nowhere else.",
|
||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
||||
"gitBar.merging": "A merge is in progress: finish or abort it first",
|
||||
"gitBar.merging": "A {{op}} is in progress: finish or abort it first",
|
||||
"changes.conflictsGroup": "Merge conflicts",
|
||||
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "Merge in progress — every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete merge",
|
||||
"changes.mergeAbort": "Abort merge",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
||||
"changes.mergeBanner": "A {{op}} is in progress \u2014 {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "A {{op}} is in progress \u2014 every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete {{op}}",
|
||||
"changes.mergeAbort": "Abort {{op}}",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
||||
"changes.discardShortConfirm": "Discard?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendHint": "Fold the staged changes into the last commit (only before it is pushed). Leave the message empty to keep the old one.",
|
||||
"changes.amendPlaceholder": "New message (empty = keep the old one)",
|
||||
"changes.historyOf": "History of branch",
|
||||
"changes.cherryPick": "Cherry-pick onto {{branch}}",
|
||||
"changes.revertCommit": "Revert this commit",
|
||||
"changes.undoCommit": "Undo commit (keep changes)",
|
||||
"changes.undoCommitTitle": "Undo the last commit?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
|
||||
"changes.undoCommitConfirm": "Undo commit",
|
||||
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
|
||||
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
||||
"changes.amendTitle": "Amend the last commit?",
|
||||
"changes.amendConfirm": "\"{{subject}}\" is replaced by a new commit with the staged changes folded in. The server refuses if it is already pushed.",
|
||||
"changes.opConflicts": "The {{op}} stopped on {{count}} conflict(s) — resolve them in Changes",
|
||||
"changes.opDone": "{{op}} done",
|
||||
"changes.takeOurs": "Keep mine",
|
||||
"changes.takeTheirs": "Take theirs",
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
||||
@@ -330,9 +353,6 @@
|
||||
"tree.allTools": "All tools",
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
|
||||
"tree.reopenTerminal": "Open a new terminal",
|
||||
"tree.forgetTerminal": "Remove from list",
|
||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
@@ -346,6 +366,7 @@
|
||||
"tree.unarchive": "Unarchive",
|
||||
"home.breadcrumb": "Workspaces",
|
||||
"home.ai": "AI",
|
||||
"home.openTerminal": "Open terminal",
|
||||
"home.repoRoot": "Repository root",
|
||||
"home.mainBranch": "Main worktree's branch",
|
||||
"home.worktreeCount_one": "{{count}} worktree",
|
||||
|
||||
@@ -93,8 +93,9 @@
|
||||
"changes.noMatches": "Không có file nào khớp.",
|
||||
"changes.openFile": "Mở {{path}}",
|
||||
"changes.quickOpen": "Tìm file theo đường dẫn…",
|
||||
"changes.range.branch": "So nhánh gốc",
|
||||
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của workspace này thêm vào so với nhánh gốc",
|
||||
"changes.range.branch": "So với {{base}}",
|
||||
"changes.range.baseBranch": "nhánh gốc",
|
||||
"changes.range.branchHint": "{{base}}...HEAD — toàn bộ phần nhánh này thêm vào so với {{base}}",
|
||||
"changes.range.work": "Chưa commit",
|
||||
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
||||
"changes.refresh": "Tải lại",
|
||||
@@ -244,6 +245,7 @@
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.revertBlock": "Hoàn tác",
|
||||
"changes.blockNotInDiff": "Thay đổi này không còn trong diff của git; hãy làm mới rồi thử lại",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.preview": "Xem trước",
|
||||
"changes.raw": "Mã nguồn",
|
||||
@@ -269,14 +271,35 @@
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong workspace sẽ thay đổi ngay - nếu có agent đang làm việc trong workspace này, hãy để nó xong trước.",
|
||||
"gitBar.pushConfirm": "Push {{count}} commit từ {{branch}} lên {{upstream}}? Ai có quyền vào remote đều thấy được, và không thể rút lại push từ đây.",
|
||||
"gitBar.publishBranchConfirm": "Publish nhánh {{branch}} lên remote? Ai có quyền vào remote đều thấy được.",
|
||||
"gitBar.publishConfirm": "Thêm {{url}} làm remote của repo và push {{branch}} lên đó? Ai có quyền vào remote đó đều thấy được code.",
|
||||
"gitBar.branchDeleteConfirm": "Xoá nhánh local {{branch}}? Git sẽ từ chối nếu nhánh còn commit chưa được merge vào đâu.",
|
||||
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
|
||||
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
|
||||
"gitBar.merging": "Đang {{op}}: hãy hoàn tất hoặc huỷ trước",
|
||||
"changes.conflictsGroup": "Conflict khi merge",
|
||||
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất merge",
|
||||
"changes.mergeAbort": "Huỷ merge",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
||||
"changes.mergeBanner": "Đang {{op}} - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang {{op}} - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất {{op}}",
|
||||
"changes.mergeAbort": "Huỷ {{op}}",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
||||
"changes.discardShortConfirm": "Xoá?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendHint": "Gộp phần đã stage vào commit cuối (chỉ khi chưa push). Để trống message để giữ message cũ.",
|
||||
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
|
||||
"changes.historyOf": "Lịch sử của nhánh",
|
||||
"changes.cherryPick": "Cherry-pick vào {{branch}}",
|
||||
"changes.revertCommit": "Revert commit này",
|
||||
"changes.undoCommit": "Hoàn tác commit (giữ thay đổi)",
|
||||
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
|
||||
"changes.undoCommitConfirm": "Hoàn tác commit",
|
||||
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.amendTitle": "Amend commit cuối?",
|
||||
"changes.amendConfirm": "\"{{subject}}\" sẽ được thay bằng một commit mới gộp cả phần đã stage. Server sẽ từ chối nếu commit đã được push.",
|
||||
"changes.opConflicts": "{{op}} dừng lại ở {{count}} file conflict — hãy giải quyết trong Changes",
|
||||
"changes.opDone": "Đã {{op}} xong",
|
||||
"changes.takeOurs": "Giữ bản của mình",
|
||||
"changes.takeTheirs": "Lấy bản kia",
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
||||
@@ -330,9 +353,6 @@
|
||||
"tree.allTools": "Mọi tool",
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
|
||||
"tree.reopenTerminal": "Mở terminal mới",
|
||||
"tree.forgetTerminal": "Xóa khỏi danh sách",
|
||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
@@ -346,6 +366,7 @@
|
||||
"tree.unarchive": "Bỏ lưu trữ",
|
||||
"home.breadcrumb": "Không gian làm việc",
|
||||
"home.ai": "AI",
|
||||
"home.openTerminal": "Mở terminal",
|
||||
"home.repoRoot": "Thư mục gốc của repo",
|
||||
"home.mainBranch": "Branch của worktree chính",
|
||||
"home.worktreeCount_one": "{{count}} worktree",
|
||||
|
||||
+10
-24
@@ -2337,17 +2337,6 @@ export const api = {
|
||||
run: {
|
||||
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
|
||||
list: () => request<RunListResponse>("/run"),
|
||||
/** GET /api/run/history - persisted runs, newest first; `laneId` narrows to
|
||||
* one workspace and `tool` (with it) to one tool's — `shell` = Terminal tabs. */
|
||||
history: (opts: { laneId: number; tool?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams({ laneId: String(opts.laneId) });
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
if (opts.limit) qs.set("limit", String(opts.limit));
|
||||
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
|
||||
},
|
||||
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
|
||||
forgetHistory: (id: string) =>
|
||||
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||
binary: (tool: LaneTool = "claude") =>
|
||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||
@@ -2902,10 +2891,12 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @param opts `file` narrows the list to the commits touching one path.
|
||||
*/
|
||||
gitLog: (id: GitTarget, opts: { limit?: number; file?: string } = {}) => {
|
||||
gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.limit) q.set("limit", String(opts.limit));
|
||||
if (opts.file) q.set("file", opts.file);
|
||||
// Another branch's history (cherry-pick's source); absent = HEAD's.
|
||||
if (opts.ref) q.set("ref", opts.ref);
|
||||
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
|
||||
},
|
||||
/**
|
||||
@@ -3021,8 +3012,14 @@ export const api = {
|
||||
| "merge"
|
||||
| "resolve"
|
||||
| "merge-commit"
|
||||
| "merge-abort",
|
||||
| "merge-abort"
|
||||
| "cherry-pick"
|
||||
| "revert"
|
||||
| "undo-commit"
|
||||
| "amend",
|
||||
body: {
|
||||
/** cherry-pick / revert: the commit. */
|
||||
sha?: string;
|
||||
url?: string;
|
||||
message?: string;
|
||||
only?: "staged";
|
||||
@@ -4283,17 +4280,6 @@ export interface RunListResponse {
|
||||
items: RunHandle[];
|
||||
}
|
||||
|
||||
/** One row of GET /api/run/history (`dashboard_runs`; only the fields read). */
|
||||
export interface RunHistoryItem {
|
||||
id: string;
|
||||
tool: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
/** The run's tmux pane is still up. */
|
||||
isLive: boolean;
|
||||
}
|
||||
|
||||
/** One suggested working directory for the Run page's cwd picker. */
|
||||
export interface CwdSuggestion {
|
||||
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
|
||||
|
||||
@@ -2837,10 +2837,16 @@ export type LaneGitFacts =
|
||||
ahead: number | null;
|
||||
/** Commits the upstream has that HEAD does not; null without an upstream. */
|
||||
behind: number | null;
|
||||
/** A merge is in progress (issue #45). Absent on an older server. */
|
||||
/** A merge, cherry-pick or revert is in progress (issue #45). Absent on
|
||||
* an older server. */
|
||||
merging?: boolean;
|
||||
/** Which one `merging` is about; absent on an older server (= merge). */
|
||||
operation?: "merge" | "cherry-pick" | "revert" | null;
|
||||
/** Files the merge still holds unmerged, repo-wide. */
|
||||
conflicts?: number;
|
||||
/** The branch "vs base" compares against: the lane's own base, else the
|
||||
* repo's default branch; null when there is none. */
|
||||
base?: string | null;
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
|
||||
@@ -7270,7 +7270,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.5.10 (rebuild client to match)
|
||||
UI build v2.5.11 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+36
-8
@@ -333,7 +333,7 @@ When the session's tool is Codex, three fields control Codex-specific behavior:
|
||||
- **`worktree: true`** (Claude workspaces, no `resumeSessionId`) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs `claude --worktree=ah-<id>-<base36 time>` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs the user's `WorktreeCreate` hook and cleans up on exit. `/.claude/worktrees/` is added to the repo root's `info/exclude`; the run is recorded with `worktree: 1` (and the run handle carries `worktree: true`). `400 EUNSUPPORTED` on any other tool. A `resumeSessionId` (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is `400 ENOWORKTREE`.
|
||||
|
||||
- **`slot`** — which Workspace session to start, `0`–`9` (ten per workspace) or `"auto"` (default `0`, else `400 EBADSLOT`). `"auto"` lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers `409 ERUNLIVE` "every tab of this workspace is busy", while a specific busy slot answers `409 ERUNLIVE` "that tab already has a live session". Slot 0 is the workspace's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab): the login shell tmux would run (`$SHELL -l`) in the workspace's folder, started only once a client is attached, so at the browser's size (`#{session_attached}` polled, at most 2 s — a prompt printed at tmux's 80 columns and then resized left the cursor on top of it, issue #55), recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
|
||||
- **`shell`** — `true` opens a plain shell in that slot instead of an AI tool (the Workspace's **New terminal** tab, or **Open terminal** in the workspace home's AI menu): the login shell tmux would run (`$SHELL -l`) in the workspace's folder, started only once a client is attached, so at the browser's size (`#{session_attached}` polled, at most 2 s — a prompt printed at tmux's 80 columns and then resized left the cursor on top of it, issue #55), recorded as tool `shell`. Every other field is ignored, nothing comes from the request but the slot, and the workspace's `run_id`/`status` stay as they are (no agent is at work). A slot with a live session answers `409 ERUNLIVE`.
|
||||
|
||||
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
|
||||
|
||||
@@ -572,13 +572,14 @@ that file.
|
||||
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 workspace with no base
|
||||
branch, and `400 ENOFILECHANGE` for a `path` with no changes in that range.
|
||||
git cannot read; `range=branch` compares against the workspace's base branch,
|
||||
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
||||
— `400 ENOBASE` when neither exists — and `400 ENOFILECHANGE` for a `path` with no changes in that range.
|
||||
|
||||
#### Read a workspace's commits, a file, or the file tree
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>][&ref=<branch>]
|
||||
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
|
||||
PUT /api/lanes/:id/git/file {path, content, baseSha}
|
||||
GET /api/lanes/:id/git/raw?path=<image>[&rev=work|HEAD|<sha>]
|
||||
@@ -595,7 +596,9 @@ answer `{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
refs, parents}]}`, newest first in `--date-order` (no parent before its
|
||||
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
|
||||
commits touching that path — relative, no `..` segment, else
|
||||
`400 EBADPATH`. Reading one commit's diff is
|
||||
`400 EBADPATH`. `ref=` reads another branch's history instead of HEAD's
|
||||
(where the panel's Cherry-pick finds its commits): a local or
|
||||
remote-tracking branch that exists, else `400 ENOBRANCH`. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
- **`PUT /git/file`** saves the whole-file editor (same-origin guarded;
|
||||
`server/lib/lane-file-write.js`). Same path guards as the read, plus: the
|
||||
@@ -723,7 +726,11 @@ cannot collide on `index.lock`.
|
||||
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
|
||||
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
|
||||
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
|
||||
| `merge-abort` | `git merge --abort` | `{"confirm": true}` |
|
||||
| `merge-abort` | `git merge --abort` (or `cherry-pick --abort` / `revert --abort`, whichever is open) | `{"confirm": true}` |
|
||||
| `cherry-pick` | `git cherry-pick <sha>` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
|
||||
| `revert` | `git revert --no-edit <sha>` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
|
||||
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": "<HEAD the user saw>"}` |
|
||||
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands | `{"message": "...", "sha": "<HEAD>"}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
@@ -731,8 +738,29 @@ cannot collide on `index.lock`.
|
||||
|
||||
- `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 rebase, reset or stash here —
|
||||
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
running agent. There is deliberately no rebase, hard reset or stash here —
|
||||
a hard `reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
- **Cherry-pick and revert** follow merge: a commit named by hex sha that
|
||||
exists (`400 EBADSHA`) and is not a merge commit (`400 EMERGECOMMIT`), a
|
||||
clean tree, a repo-root workspace. A conflict is a **200** with
|
||||
`conflicts[]`, resolved with `resolve` and finished by `merge-commit` (which
|
||||
keeps a pick's original author) or dropped by `merge-abort`; `GET /git` then
|
||||
reports `merging: true` and `operation: "cherry-pick"|"revert"`. A pick or
|
||||
revert whose changes are already there is aborted again and answers
|
||||
`409 EEMPTY`, never left open; one git could not commit (no identity, a
|
||||
failing signer) is undone the same way and answers git's own error. A
|
||||
rebase stopped in a terminal makes every history action, `merge-commit` and
|
||||
`merge-abort` `409 EMERGING` — it is finished where it started.
|
||||
- **Undo commit and amend** rewrite the last commit, so both are
|
||||
`409 EPUSHED` once any remote-tracking branch contains HEAD, `409 ESTALE`
|
||||
when HEAD is no longer the `sha` the request names (`400 EBADSHA` without
|
||||
one — an agent may have committed since the UI drew the commit), and
|
||||
`400 ESUBDIR` outside a repo-root workspace. `undo-commit` needs
|
||||
`confirm: true` and refuses
|
||||
the first commit (`400 ENOPARENT`) and a merge commit (`400 EMERGECOMMIT`);
|
||||
it never touches the working tree. `amend` with neither a message nor a
|
||||
staged change is `400 ENOCHANGES`. All four are `409 EMERGING` while a
|
||||
merge, cherry-pick or revert is open.
|
||||
- **Merge** (issue #45) happens only when asked for by name: on a clean tree
|
||||
(`400 EDIRTY`), from a workspace at the repo root (`400 ESUBDIR` — a merge is
|
||||
repo-wide), for a branch listed under `refs/heads` or `refs/remotes`
|
||||
|
||||
+20
-12
@@ -40,8 +40,8 @@ Adding a workspace through the dashboard's "+ Add workspace" flow also auto-runs
|
||||
|
||||
The modal has a segmented toggle mirroring the CLI's two modes:
|
||||
|
||||
- **Repo** — adopts the given directory as-is (maps to `agenthub workspace add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
|
||||
- **Worktree** (default) — provisions a managed worktree (maps to `agenthub workspace add --repo`).
|
||||
- **Repo** (default) — adopts the given directory as-is (maps to `agenthub workspace add --cwd`). No branch fields; the auto-setup summary above does not run (adopting is instant, nothing to wait on).
|
||||
- **Worktree** — provisions a managed worktree (maps to `agenthub workspace add --repo`). The git bar's "new workspace from this branch" opens the modal here, with the branch filled in.
|
||||
|
||||
In Worktree mode the branch question has three answers, because git's own rule
|
||||
decides which is available (issue #34):
|
||||
@@ -93,7 +93,7 @@ Both modes' path field has a **Browse** button next to it that opens the operati
|
||||
|
||||
## Workspace tool: Claude or Codex
|
||||
|
||||
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Proxy, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
|
||||
A workspace has a default tool: **Claude** (default) or **Codex** (or any AI Tools id), chosen in the "+ Add workspace" modal and immutable once the workspace exists. Each NEW session picks its own in the workspace home's prompt box — the **AI** select at the left of the box (beside it, on the right, ONE model chip holding the model list and Effort; AI Proxy, Permission and Local / New worktree sit in a row under the box, like Claude Desktop), lists every installed, enabled tool and starts on the workspace's (its last row, **Open terminal**, is no tool: it opens a plain shell tab right away, `shell: true` in the first free slot, and leaves the picked AI alone) — and `POST /api/lanes/:id/start` takes it as `tool`; a resume keeps the tool its session ran on (`sessions.tool`). The sidebar draws each session's tool icon at the right of its row.
|
||||
|
||||
- **`claude` (default)** — `claude` binary is invoked; the session's hooks, messages, and tool calls flow through the dashboard. Claude workspaces render Sessions, Analytics, cost, and stage-detection badges; liveness is tracked by the Claude Code session.
|
||||
- **`codex` (experimental)** — `codex` binary is invoked instead. The home's prompt box shows Sandbox and Approval in place of Claude's permission mode. Codex workspaces carry **no pipeline**: no stage chip, no time-on-stage, no pipeline or feature picker and no PipelineMap, and nothing infers or advances their stage. They also get no Sessions/Analytics/cost. (The console pre-fills nothing into the prompt box, for either tool.)
|
||||
@@ -636,17 +636,22 @@ reads the workspace's git surface in five modes:
|
||||
`/git/changes`): **Changes** folds the unchanged stretches away (a click on
|
||||
a fold opens it), **Whole file** shows them, and the toggle keeps the draft.
|
||||
Unified puts the removed lines read-only above their replacement; side by
|
||||
side puts the original, read-only, left of the file. Every changed block
|
||||
carries a **Revert** (`→` between split columns) that puts the original back
|
||||
in the editor — nothing is written until Save. The panel opens side by side
|
||||
side puts the original, read-only, left of the file. While the editor holds
|
||||
no unsaved text, every changed block of a file with unstaged changes
|
||||
carries the review bar's **Undo** (asks twice) / **Keep** under its last
|
||||
line — the same `discard-hunk` / `stage-hunk` as the diff, on the file on
|
||||
disk, matched to git's own change block by its line span (a block git
|
||||
splits differently says so instead of acting); an Undo reloads the editor.
|
||||
Once you type, the bar steps aside and each block carries a **Revert**
|
||||
(`→` between split columns) that puts the original back in the editor —
|
||||
nothing is written until Save. The panel opens side by side
|
||||
at 50/50 and full width (Workspace's `wide`) and unified when narrower; the
|
||||
layout button overrides it until the next resize. The colours follow every
|
||||
keystroke: **green** is a line added whole, **red** a line removed whole,
|
||||
**blue** a line edited in place (it keeps a letter or digit of its old
|
||||
text), its edit in a stronger blue; the read-only diffs (Review, a commit, a
|
||||
file the editor cannot take) use the same scheme, and only they carry the
|
||||
per-block Stage/Undo review bars and the line-comment buttons — Review
|
||||
keeps both for every file. In a read-only side-by-side diff, every row is at
|
||||
line-comment buttons — Review keeps them for every file. In a read-only side-by-side diff, every row is at
|
||||
least one line tall (`MIN_ROW`), so an empty filler row keeps the two
|
||||
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
|
||||
shown as the picture (`/git/raw`), and a conflicted file opens in the
|
||||
@@ -695,10 +700,13 @@ reads the workspace's git surface in five modes:
|
||||
opening one shows that commit's diff (`?range=commit&sha=`).
|
||||
|
||||
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
|
||||
working tree, untracked files included as an all-added patch) and **vs base**
|
||||
(`<base>...HEAD` — what this workspace's branch adds on top of `base_branch`; a workspace
|
||||
with no base says so rather than guessing one). Opening a commit from History
|
||||
adds a third range, shown as a short-sha chip.
|
||||
working tree, untracked files included as an all-added patch) and **Compared to
|
||||
<base>** (`<base>...HEAD` — what this branch adds on top of the workspace's
|
||||
`base_branch`, else the repo's default branch: what `origin/HEAD` names, else
|
||||
`main`/`master`; `GET /api/lanes/:id/git` reports it as `base`). With no base, or
|
||||
on the base branch itself, there is nothing to compare and no toggle is drawn.
|
||||
Opening a commit from History adds a third range, shown as a short-sha chip
|
||||
beside **Uncommitted**, the way back.
|
||||
|
||||
The viewer itself (issue #35) is VS Code-shaped: a **unified / side-by-side**
|
||||
toggle, syntax highlighting from the tokenizer the conversation view already
|
||||
|
||||
+18
-7
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.5.10
|
||||
version: 2.5.11
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -10623,7 +10623,7 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).
|
||||
description: Return only the runs of this AI Tools id — `shell` for a plain terminal tab (a closed one's row is dropped on the next run-heal beat).
|
||||
example: shell
|
||||
responses:
|
||||
'200':
|
||||
@@ -10664,7 +10664,7 @@ paths:
|
||||
tags:
|
||||
- Run
|
||||
summary: Forget a closed Terminal tab
|
||||
description: Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.
|
||||
description: Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session (the run-heal beat drops these rows anyway; this removes one at once). Rows of any other tool are never deleted (404). The loopback same-origin guard applies.
|
||||
operationId: runHistoryForget
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -11487,7 +11487,7 @@ paths:
|
||||
'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.
|
||||
description: ENOBASE (range=branch with neither a lane base branch nor a repo default branch to compare against), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/log:
|
||||
@@ -11495,7 +11495,7 @@ paths:
|
||||
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 (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
|
||||
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH); ref= reads another existing local or remote-tracking branch's history instead of HEAD's (else 400 ENOBRANCH). 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
|
||||
@@ -11513,11 +11513,15 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: ref
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: '{available, commits:[{sha, short, subject, author, date, refs}]}.'
|
||||
'400':
|
||||
description: EBADPATH (file= is absolute or has a `..` segment).
|
||||
description: EBADPATH (file= is absolute or has a `..` segment), ENOBRANCH (ref= is not a branch).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/file:
|
||||
@@ -11737,6 +11741,10 @@ paths:
|
||||
- branch-create
|
||||
- branch-delete
|
||||
- publish
|
||||
- cherry-pick
|
||||
- revert
|
||||
- undo-commit
|
||||
- amend
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -11749,7 +11757,10 @@ paths:
|
||||
description: publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.
|
||||
message:
|
||||
type: string
|
||||
description: commit only; required, max 4000 chars.
|
||||
description: commit (required) and amend (optional — none keeps the old message); max 4000 chars.
|
||||
sha:
|
||||
type: string
|
||||
description: cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR).
|
||||
only:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.10",
|
||||
"version": "2.5.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.5.10",
|
||||
"version": "2.5.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.5.10",
|
||||
"version": "2.5.11",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+2
-2
@@ -538,7 +538,7 @@ Request body shape:
|
||||
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the workspace's log directory. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The workspace's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=&ref=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path, or another existing branch's (`ref=`, else `400 ENOBRANCH`). |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
@@ -561,7 +561,7 @@ Request body shape:
|
||||
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
|
||||
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
|
||||
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. No rebase/hard reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
an existing absolute git repository. The response workspace starts as
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("dashboard-runs", () => {
|
||||
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
|
||||
});
|
||||
|
||||
it("keeps only the newest closed Terminal tabs of a lane, live ones untouched", () => {
|
||||
it("drops every closed Terminal tab of a lane, live ones untouched", () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
start(`agenthub-lane-11-${i}`, {
|
||||
laneId: 11,
|
||||
@@ -73,12 +73,7 @@ describe("dashboard-runs", () => {
|
||||
// Pane 5 still runs; every other one has closed.
|
||||
runs.reconcileOrphans((id) => id === "agenthub-lane-11-5");
|
||||
const shells = runs.listRuns({ laneId: 11, tool: "shell" }).map((r) => r.id);
|
||||
assert.deepEqual(shells, [
|
||||
"agenthub-lane-11-5",
|
||||
"agenthub-lane-11-4",
|
||||
"agenthub-lane-11-3",
|
||||
"agenthub-lane-11-2",
|
||||
]);
|
||||
assert.deepEqual(shells, ["agenthub-lane-11-5"]);
|
||||
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
|
||||
});
|
||||
|
||||
|
||||
@@ -203,10 +203,12 @@ describe("collectChanges — branch range", () => {
|
||||
assert.equal(out.files[0].added, 1);
|
||||
});
|
||||
|
||||
it("refuses when the lane has no base to measure against", async () => {
|
||||
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: null }), {
|
||||
code: "ENOBASE",
|
||||
});
|
||||
it("falls back to the default branch without a base, and refuses a base that is gone", async () => {
|
||||
// No recorded base (an adopted folder): what origin/HEAD names.
|
||||
assert.equal(
|
||||
(await collectChanges(LANE, { range: "branch", base: null })).base,
|
||||
"development"
|
||||
);
|
||||
await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), {
|
||||
code: "ENOBASE",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @file The history actions against a REAL repo: cherry-pick and revert
|
||||
* (clean tree, repo-root lane, a commit that exists and is not a merge, a
|
||||
* conflict reported and finished/aborted through merge-commit/merge-abort,
|
||||
* an empty pick undone rather than left open), undo-commit (confirm, never a
|
||||
* pushed commit, never the root or a merge commit, changes kept staged) and
|
||||
* amend (never a pushed commit), plus a log read of another branch and the
|
||||
* "vs base" range falling back to the repo's default branch.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
const { listCommits, collectChanges } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-history-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
const REMOTE = path.join(ROOT, "remote.git");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
const sha = (ref) => g("rev-parse", ref).trim();
|
||||
const subject = (ref = "HEAD") => g("log", "-1", "--format=%s", ref).trim();
|
||||
|
||||
// main: init -> main ; feat (from init): feat-a (edits a.txt), feat-b (adds b.txt)
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.rmSync(REMOTE, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(REPO, "sub"), { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
write("a.txt", "base\n");
|
||||
write("sub/s.txt", "s\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
g("switch", "-qc", "feat");
|
||||
write("a.txt", "theirs\n");
|
||||
g("commit", "-qam", "feat-a");
|
||||
write("b.txt", "only on feat\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "feat-b");
|
||||
g("switch", "-q", "main");
|
||||
write("a.txt", "ours\n");
|
||||
g("commit", "-qam", "main");
|
||||
});
|
||||
|
||||
describe("cherry-pick", () => {
|
||||
it("applies one commit from another branch", async () => {
|
||||
const r = await runGitAction(REPO, "cherry-pick", { sha: sha("feat") });
|
||||
assert.deepEqual(r.conflicts, []);
|
||||
assert.equal(read("b.txt"), "only on feat\n");
|
||||
assert.equal(subject(), "feat-b");
|
||||
});
|
||||
|
||||
it("reports a conflict, then finishes it with merge-commit keeping the author", async () => {
|
||||
g("switch", "-qc", "other", "main~1");
|
||||
write("a.txt", "other\n");
|
||||
g("-c", "user.name=Other", "-c", "user.email=o@h", "commit", "-qam", "by-other");
|
||||
g("switch", "-q", "main");
|
||||
const r = await runGitAction(REPO, "cherry-pick", { sha: sha("other") });
|
||||
assert.deepEqual(r.conflicts, ["a.txt"]);
|
||||
const facts = await gitFacts(REPO);
|
||||
assert.equal(facts.merging, true);
|
||||
assert.equal(facts.operation, "cherry-pick");
|
||||
await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), { code: "EMERGING" });
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" });
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
|
||||
await runGitAction(REPO, "merge-commit");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(subject(), "by-other");
|
||||
assert.equal(read("a.txt"), "other\n");
|
||||
assert.equal(g("log", "-1", "--format=%an", "HEAD").trim(), "Other");
|
||||
});
|
||||
|
||||
it("aborts a conflicted pick with confirm, back to where it started", async () => {
|
||||
const before = sha("HEAD");
|
||||
await runGitAction(REPO, "cherry-pick", { sha: sha("feat~1") });
|
||||
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
|
||||
await runGitAction(REPO, "merge-abort", { confirm: true });
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(sha("HEAD"), before);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
});
|
||||
|
||||
it("undoes an empty pick instead of leaving it open", async () => {
|
||||
await runGitAction(REPO, "cherry-pick", { sha: sha("feat") });
|
||||
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), {
|
||||
code: "EEMPTY",
|
||||
});
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
});
|
||||
|
||||
it("refuses a dirty tree, a bad or unknown sha, a merge commit and a subdirectory lane", async () => {
|
||||
write("a.txt", "wip\n");
|
||||
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: sha("feat") }), {
|
||||
code: "EDIRTY",
|
||||
});
|
||||
g("checkout", "--", "a.txt");
|
||||
for (const bad of [undefined, "", "--help", "HEAD", "zzzz", "feat"]) {
|
||||
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: bad }), { code: "EBADSHA" });
|
||||
}
|
||||
await assert.rejects(runGitAction(REPO, "cherry-pick", { sha: "deadbeefdeadbeef" }), {
|
||||
code: "EBADSHA",
|
||||
});
|
||||
g("merge", "-q", "-X", "ours", "--no-edit", "feat");
|
||||
await assert.rejects(runGitAction(REPO, "revert", { sha: sha("HEAD") }), {
|
||||
code: "EMERGECOMMIT",
|
||||
});
|
||||
await assert.rejects(
|
||||
runGitAction(path.join(REPO, "sub"), "cherry-pick", { sha: sha("feat") }),
|
||||
{ code: "ESUBDIR" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revert", () => {
|
||||
it("adds a commit that undoes an older one", async () => {
|
||||
g("switch", "-q", "feat");
|
||||
const r = await runGitAction(REPO, "revert", { sha: sha("feat~1") });
|
||||
assert.deepEqual(r.conflicts, []);
|
||||
assert.equal(read("a.txt"), "base\n");
|
||||
assert.match(subject(), /^Revert "feat-a"/);
|
||||
});
|
||||
|
||||
it("reports a conflict and aborts it", async () => {
|
||||
write("a.txt", "later\n");
|
||||
g("commit", "-qam", "later");
|
||||
await runGitAction(REPO, "revert", { sha: sha("HEAD~1") });
|
||||
assert.equal((await gitFacts(REPO)).operation, "revert");
|
||||
await runGitAction(REPO, "merge-abort", { confirm: true });
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(read("a.txt"), "later\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("undo-commit", () => {
|
||||
it("drops the last commit and keeps its changes staged", async () => {
|
||||
await assert.rejects(runGitAction(REPO, "undo-commit"), { code: "ECONFIRM" });
|
||||
const parent = sha("HEAD~1");
|
||||
await runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") });
|
||||
assert.equal(sha("HEAD"), parent);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
|
||||
});
|
||||
|
||||
it("never touches a pushed commit, the root commit or a merge commit", async () => {
|
||||
execFileSync("git", ["init", "-q", "--bare", REMOTE]);
|
||||
g("remote", "add", "origin", REMOTE);
|
||||
g("push", "-q", "origin", "main");
|
||||
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), {
|
||||
code: "EPUSHED",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: sha("HEAD") }), {
|
||||
code: "EPUSHED",
|
||||
});
|
||||
|
||||
g("switch", "-q", "--orphan", "solo");
|
||||
g("commit", "-qm", "root", "--allow-empty");
|
||||
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), {
|
||||
code: "ENOPARENT",
|
||||
});
|
||||
|
||||
g("switch", "-q", "-f", "feat");
|
||||
g("merge", "-q", "--no-ff", "--no-edit", "-X", "ours", "main");
|
||||
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: sha("HEAD") }), {
|
||||
code: "EMERGECOMMIT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("amend", () => {
|
||||
it("rewrites the message, or folds staged changes in keeping it", async () => {
|
||||
const parent = sha("HEAD~1");
|
||||
await runGitAction(REPO, "amend", { message: "main, reworded", sha: sha("HEAD") });
|
||||
assert.equal(subject(), "main, reworded");
|
||||
assert.equal(sha("HEAD~1"), parent);
|
||||
|
||||
await assert.rejects(runGitAction(REPO, "amend", { sha: sha("HEAD") }), { code: "ENOCHANGES" });
|
||||
write("c.txt", "c\n");
|
||||
g("add", "c.txt");
|
||||
await runGitAction(REPO, "amend", { sha: sha("HEAD") });
|
||||
assert.equal(subject(), "main, reworded");
|
||||
assert.equal(sha("HEAD~1"), parent);
|
||||
assert.match(g("show", "--name-only", "--format=", "HEAD"), /c\.txt/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("log of another branch", () => {
|
||||
it("lists a named branch's commits and refuses a ref that is not a branch", async () => {
|
||||
const commits = await listCommits(REPO, { ref: "feat" });
|
||||
assert.deepEqual(
|
||||
commits.map((c) => c.subject),
|
||||
["feat-b", "feat-a", "init"]
|
||||
);
|
||||
await assert.rejects(listCommits(REPO, { ref: "--all" }), { code: "ENOBRANCH" });
|
||||
await assert.rejects(listCommits(REPO, { ref: "nope" }), { code: "ENOBRANCH" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("vs base without a recorded base", () => {
|
||||
it("compares against the repo's default branch", async () => {
|
||||
g("switch", "-q", "feat");
|
||||
const r = await collectChanges(REPO, { range: "branch", base: null });
|
||||
assert.equal(r.base, "main");
|
||||
assert.deepEqual(
|
||||
r.files.map((f) => f.path),
|
||||
["a.txt", "b.txt"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("review fixes", () => {
|
||||
it("undo and amend act only on the HEAD the user saw", async () => {
|
||||
const seen = sha("HEAD");
|
||||
write("a.txt", "agent\n");
|
||||
g("commit", "-qam", "agent committed meanwhile");
|
||||
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true, sha: seen }), {
|
||||
code: "ESTALE",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "amend", { message: "x", sha: seen }), {
|
||||
code: "ESTALE",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "undo-commit", { confirm: true }), { code: "EBADSHA" });
|
||||
assert.equal(subject(), "agent committed meanwhile");
|
||||
});
|
||||
|
||||
it("a pick or revert that fails at commit time reports git's error and leaves the tree clean", async () => {
|
||||
g("config", "commit.gpgsign", "true");
|
||||
g("config", "gpg.program", "false");
|
||||
for (const [action, ref] of [["cherry-pick", "feat"], ["revert", "HEAD"]]) {
|
||||
const before = sha("HEAD");
|
||||
await assert.rejects(runGitAction(REPO, action, { sha: sha(ref) }), (e) => {
|
||||
assert.notEqual(e.code, "EEMPTY");
|
||||
assert.match(e.git?.stderr || "", /gpg/i);
|
||||
return true;
|
||||
});
|
||||
assert.equal(sha("HEAD"), before);
|
||||
assert.equal(g("status", "--porcelain"), "");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
}
|
||||
});
|
||||
|
||||
it("a revert of something already reverted is EEMPTY", async () => {
|
||||
const target = sha("HEAD");
|
||||
await runGitAction(REPO, "revert", { sha: target });
|
||||
g("revert", "--no-edit", "HEAD");
|
||||
write("a.txt", "base\n");
|
||||
g("commit", "-qam", "undo by hand");
|
||||
await assert.rejects(runGitAction(REPO, "revert", { sha: target }), { code: "EEMPTY" });
|
||||
assert.equal(g("status", "--porcelain"), "");
|
||||
});
|
||||
|
||||
it("amend refuses a subdirectory lane", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(path.join(REPO, "sub"), "amend", { message: "x", sha: sha("HEAD") }),
|
||||
{ code: "ESUBDIR" }
|
||||
);
|
||||
});
|
||||
|
||||
it("a rebase started in a terminal blocks every history action", async () => {
|
||||
g("switch", "-q", "feat");
|
||||
const env = { ...process.env, GIT_SEQUENCE_EDITOR: "sed -i 1s/^pick/edit/" };
|
||||
delete env.GIT_DIR;
|
||||
execFileSync("git", ["rebase", "-q", "-i", "HEAD~2"], { cwd: REPO, env, stdio: "ignore" });
|
||||
for (const [action, body] of [
|
||||
["undo-commit", { confirm: true, sha: sha("HEAD") }],
|
||||
["amend", { message: "x", sha: sha("HEAD") }],
|
||||
["cherry-pick", { sha: sha("main") }],
|
||||
["merge-commit", {}],
|
||||
]) {
|
||||
await assert.rejects(runGitAction(REPO, action, body), { code: "EMERGING" });
|
||||
}
|
||||
g("rebase", "--abort");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const { getUpdatesStatus } = require("../lib/update-check");
|
||||
const { getUpdatesStatus, npmInvocation } = require("../lib/update-check");
|
||||
|
||||
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
|
||||
// very suite) sets in its own environment — those leak to every child
|
||||
@@ -182,3 +182,20 @@ describe("getUpdatesStatus — no remotes configured", () => {
|
||||
assert.match(result.message, /No git remotes configured/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("npmInvocation (the checkout's Update now)", () => {
|
||||
it("drops NODE_ENV so `npm run setup` keeps the client's build toolchain", () => {
|
||||
// The server defaults NODE_ENV=production; npm then omits devDependencies,
|
||||
// and vite/typescript are dev deps — the `npm run build` after it failed.
|
||||
const npm = npmInvocation(["run", "setup"], { NODE_ENV: "production", PATH: "/usr/bin" });
|
||||
assert.equal(npm.env.NODE_ENV, undefined);
|
||||
assert.equal(npm.env.PATH.split(path.delimiter).at(-1), "/usr/bin");
|
||||
});
|
||||
|
||||
it("runs the node's own npm, never a bare npm.cmd through execFile", () => {
|
||||
// execFile("npm.cmd") with no shell is EINVAL on Node >= 20.12.2 (Windows).
|
||||
const npm = npmInvocation(["run", "build"], {});
|
||||
assert.ok(npm.file === process.execPath || npm.shell === (process.platform === "win32"));
|
||||
assert.deepEqual(npm.args.slice(-2), ["run", "build"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,19 +265,10 @@ const abandonStmt = db.prepare(`
|
||||
WHERE id = ? AND status IN ('running', 'spawning')
|
||||
`);
|
||||
|
||||
/** Closed Terminal tabs kept per lane — what the sidebar lists
|
||||
* (`CLOSED_TERMINALS` in WorkspaceSessionList.tsx). */
|
||||
const CLOSED_SHELLS_KEPT = 3;
|
||||
// A shell has no session, so its row is only that list's entry: one the list
|
||||
// cannot show is dropped, or removing the shown ones uncovers older ones.
|
||||
// A shell has no session, so its row only tracks the open tab: once closed it
|
||||
// is dropped — the sidebar keeps no list of closed Terminal tabs.
|
||||
const pruneShellsStmt = db.prepare(`
|
||||
DELETE FROM dashboard_runs WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY lane_id ORDER BY started_at DESC) AS n
|
||||
FROM dashboard_runs
|
||||
WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
|
||||
) WHERE n > ${CLOSED_SHELLS_KEPT}
|
||||
)
|
||||
DELETE FROM dashboard_runs WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
|
||||
`);
|
||||
|
||||
/**
|
||||
@@ -285,8 +276,8 @@ const pruneShellsStmt = db.prepare(`
|
||||
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
|
||||
* A pane is a tmux session, which outlives a server restart, so liveness is
|
||||
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
|
||||
* and on the server's run-heal beat, which also drops the closed Terminal
|
||||
* tabs past the newest {@link CLOSED_SHELLS_KEPT} of each lane.
|
||||
* and on the server's run-heal beat, which also drops every closed Terminal
|
||||
* tab's row.
|
||||
*
|
||||
* Returns the number of rows updated.
|
||||
*/
|
||||
|
||||
+30
-2
@@ -258,6 +258,23 @@ async function resolveBaseRef(dir, base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo's default branch, for a lane that recorded no base of its own (an
|
||||
* adopted folder, a session's `claude --worktree` tree): what `origin/HEAD`
|
||||
* points at, else `main` or `master` when one exists, else null.
|
||||
*/
|
||||
async function defaultBranch(dir) {
|
||||
const head = await git(dir, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]).then(
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
if (head.startsWith("origin/")) return head.slice("origin/".length);
|
||||
for (const name of ["main", "master"]) {
|
||||
if (await resolveBaseRef(dir, name)) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `git diff --numstat -z` output → Map<path, {added, deleted, binary}>. `-z`
|
||||
* because without it git C-quotes any path with a space, a quote or a
|
||||
@@ -501,7 +518,8 @@ async function synthesizeAddedPatch(dir, file) {
|
||||
* @param {string} dir Lane working directory.
|
||||
* @param {object} opts
|
||||
* @param {"work"|"branch"|"commit"} [opts.range="work"]
|
||||
* @param {string|null} [opts.base] The lane's base branch, for `range="branch"`.
|
||||
* @param {string|null} [opts.base] The lane's base branch, for `range="branch"`;
|
||||
* none falls back to the repo's default branch (`defaultBranch`).
|
||||
* @param {string|null} [opts.sha] The commit to read, for `range="commit"`.
|
||||
* @param {string|null} [opts.file] Only this path, with its hunks (single-file mode).
|
||||
* @param {boolean} [opts.full] Hunks for EVERY file (review mode). Ignored when
|
||||
@@ -524,6 +542,7 @@ async function collectChanges(
|
||||
// reads a file's original text there.
|
||||
let mergeBase = null;
|
||||
if (range === "branch") {
|
||||
base = base || (await defaultBranch(dir));
|
||||
baseRef = await resolveBaseRef(dir, base);
|
||||
if (!baseRef) {
|
||||
throw badRequest("ENOBASE", "no base branch to compare against");
|
||||
@@ -618,13 +637,21 @@ async function collectChanges(
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.limit=30] Commits to read, clamped to 200.
|
||||
* @param {string|null} [opts.file] Only commits touching this path.
|
||||
* @param {string|null} [opts.ref] Another branch's history instead of HEAD's
|
||||
* (where cherry-pick finds its commits): a short branch name, looked up
|
||||
* with `branchRef` and ENOBRANCH when it is not one.
|
||||
*/
|
||||
async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
async function listCommits(dir, { limit = 30, file = null, ref = null } = {}) {
|
||||
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
|
||||
const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
|
||||
// --date-order: no parent before all its children, which the history's
|
||||
// commit graph needs to draw a lane down to each parent.
|
||||
const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`];
|
||||
if (ref) {
|
||||
const full = await worktree.branchRef(dir, ref);
|
||||
if (!full) throw badRequest("ENOBRANCH", `no branch ${ref}`);
|
||||
args.push(full);
|
||||
}
|
||||
// Through the same lexical guard as every other path: in a subdirectory
|
||||
// lane `../x` would list commit metadata for a path outside the lane.
|
||||
if (file) args.push("--", lanePath(file));
|
||||
@@ -1090,6 +1117,7 @@ module.exports = {
|
||||
readFileAt,
|
||||
readImageAt,
|
||||
resolveBaseRef,
|
||||
defaultBranch,
|
||||
DEFAULT_CONTEXT,
|
||||
MAX_CONTEXT,
|
||||
MAX_FILES,
|
||||
|
||||
+206
-54
@@ -3,12 +3,20 @@
|
||||
* stage/unstage a file, commit, discard a file, switch branch, create or
|
||||
* delete a branch, publish a repo that has no remote yet, and merge a branch
|
||||
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
|
||||
* hand-edited, merge-commit, merge-abort). There is no rebase, no reset and
|
||||
* no stash, because a dashboard button is the wrong place to start an
|
||||
* operation whose recovery needs a terminal (`reset` stays where it was:
|
||||
* behind the three-check destroy guard in worktree.js). A merge is the
|
||||
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
|
||||
* and no stash, because a dashboard button is the wrong place to start an
|
||||
* operation whose recovery needs a terminal (a hard `reset` stays where it
|
||||
* was: behind the three-check destroy guard in worktree.js). A merge is the
|
||||
* exception because its recovery is ONE button here too — `merge-abort` puts
|
||||
* the branch back where it was.
|
||||
* the branch back where it was — and `cherry-pick` and `revert` follow it:
|
||||
* same clean-tree/repo-root guards, same conflict flow, and `merge-commit`/
|
||||
* `merge-abort` finish or abort whichever of the three git has open.
|
||||
*
|
||||
* `undo-commit` (`reset --soft HEAD~1`) and `amend` rewrite the last commit,
|
||||
* so both refuse once any remote-tracking branch holds it (EPUSHED): an
|
||||
* unpushed commit is the only one whose rewrite costs nobody else anything.
|
||||
* `undo-commit` keeps every change staged, never touches the working tree,
|
||||
* and still asks for `confirm: true` because the commit itself is dropped.
|
||||
*
|
||||
* Four rules hold for every action:
|
||||
* - argv only, via worktree.js's `git()` — never a shell string.
|
||||
@@ -48,6 +56,8 @@ const {
|
||||
listBranches,
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
operationInProgress,
|
||||
branchRef,
|
||||
} = require("./worktree");
|
||||
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const gitCredentials = require("./git-credentials");
|
||||
@@ -77,10 +87,41 @@ const ACTIONS = new Set([
|
||||
"resolve",
|
||||
"merge-commit",
|
||||
"merge-abort",
|
||||
"cherry-pick",
|
||||
"revert",
|
||||
"undo-commit",
|
||||
"amend",
|
||||
]);
|
||||
|
||||
/** Actions that would tangle with a merge in progress. */
|
||||
const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]);
|
||||
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
|
||||
const BLOCKED_WHILE_MERGING = new Set([
|
||||
"pull",
|
||||
"switch",
|
||||
"merge",
|
||||
"stage-hunk",
|
||||
"discard-hunk",
|
||||
"cherry-pick",
|
||||
"revert",
|
||||
"undo-commit",
|
||||
"amend",
|
||||
]);
|
||||
|
||||
/** A commit as the request may name it: an abbreviated or full hex sha
|
||||
* (64 = a SHA-256 repository). */
|
||||
const SHA_RE = /^[0-9a-f]{4,64}$/i;
|
||||
|
||||
/**
|
||||
* Whether a rebase (started in a terminal) is stopped in this repo. It leaves
|
||||
* no *_HEAD the dashboard finishes, so it gets no banner and no button — but
|
||||
* nothing here may move HEAD or commit underneath it either.
|
||||
*/
|
||||
async function rebaseInProgress(dir) {
|
||||
for (const name of ["rebase-merge", "rebase-apply"]) {
|
||||
const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim();
|
||||
if (fs.existsSync(path.resolve(dir, rel))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A line git writes to open or close a conflict block. */
|
||||
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
|
||||
@@ -280,10 +321,19 @@ async function networkGit(dir, args, body = {}) {
|
||||
*/
|
||||
async function runGitAction(dir, action, body = {}) {
|
||||
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
|
||||
if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) {
|
||||
throw bad("EMERGING", "a merge is in progress: finish or abort it first");
|
||||
if (BLOCKED_WHILE_MERGING.has(action) || action === "merge-commit" || action === "merge-abort") {
|
||||
// A rebase blocks even Complete/Abort: `commit` mid-rebase would make a
|
||||
// commit the rebase never planned. It is finished where it was started.
|
||||
if (await rebaseInProgress(dir)) {
|
||||
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
|
||||
}
|
||||
const open = BLOCKED_WHILE_MERGING.has(action) && (await operationInProgress(dir));
|
||||
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
|
||||
}
|
||||
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
|
||||
if (["cherry-pick", "revert", "undo-commit", "amend"].includes(action)) {
|
||||
return historyAction(dir, action, body);
|
||||
}
|
||||
|
||||
if (action === "fetch") {
|
||||
return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body));
|
||||
@@ -475,16 +525,6 @@ async function runGitAction(dir, action, body = {}) {
|
||||
return done(action, await git(dir, ["switch", branch]), branch);
|
||||
}
|
||||
|
||||
/** Whether a merge is in progress (MERGE_HEAD exists). */
|
||||
async function isMerging(dir) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paths git still holds as unmerged, repo-wide. */
|
||||
async function unmergedPaths(dir) {
|
||||
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
|
||||
@@ -499,42 +539,12 @@ async function mergeAction(dir, action, body) {
|
||||
if (!(await isValidBranchName(dir, branch))) {
|
||||
throw bad("EBADBRANCH", "branch is not a valid git branch name");
|
||||
}
|
||||
// A merge moves the whole repo, so only a lane that IS the repo may start
|
||||
// one — a subdirectory lane could not see, let alone resolve, a conflict
|
||||
// outside its own folder.
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad(
|
||||
"ESUBDIR",
|
||||
"a merge changes the whole repository; run it from a workspace at the repo root"
|
||||
);
|
||||
}
|
||||
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
await assertCleanRepoRoot(dir, "a merge");
|
||||
// The ref is looked up, never passed through: git sees the full refname
|
||||
// of a local or remote-tracking branch that exists, nothing else.
|
||||
const refs = (
|
||||
await git(dir, [
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)%00%(refname)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
])
|
||||
).stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => l.split("\0"));
|
||||
const ref = refs.find(([short]) => short === branch)?.[1];
|
||||
const ref = await branchRef(dir, branch);
|
||||
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
|
||||
try {
|
||||
const out = await git(dir, ["merge", "--no-edit", ref]);
|
||||
return { ...done(action, out, branch), conflicts: [] };
|
||||
} catch (e) {
|
||||
// A conflict is a merge that STARTED: report the files, not a failure.
|
||||
if (!(await isMerging(dir))) throw e;
|
||||
return {
|
||||
...done(action, e.git || { stdout: "", stderr: "" }, branch),
|
||||
conflicts: await unmergedPaths(dir),
|
||||
};
|
||||
}
|
||||
return startOperation(dir, action, ["merge", "--no-edit", ref], branch);
|
||||
}
|
||||
|
||||
if (action === "resolve") {
|
||||
@@ -564,14 +574,156 @@ async function mergeAction(dir, action, body) {
|
||||
if (action === "merge-abort" && body.confirm !== true) {
|
||||
throw bad("ECONFIRM", "merge-abort requires confirm: true");
|
||||
}
|
||||
if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress");
|
||||
if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"]));
|
||||
const open = await operationInProgress(dir);
|
||||
if (!open) throw bad("ENOMERGE", "no merge, cherry-pick or revert in progress");
|
||||
if (action === "merge-abort") return done(action, await git(dir, [open, "--abort"]));
|
||||
// merge-commit
|
||||
const left = await unmergedPaths(dir);
|
||||
if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
|
||||
// `commit` finishes all three: it takes MERGE_MSG, a pick keeps its
|
||||
// original author, and git clears the *_HEAD it was holding.
|
||||
return done(action, await git(dir, ["commit", "--no-edit"]));
|
||||
}
|
||||
|
||||
/**
|
||||
* A merge, cherry-pick or revert moves the whole repo, so only a lane that
|
||||
* IS the repo may start one — a subdirectory lane could not see, let alone
|
||||
* resolve, a conflict outside its own folder — and only on a clean tree, so
|
||||
* an abort can never take an agent's uncommitted work with it.
|
||||
*/
|
||||
async function assertCleanRepoRoot(dir, what) {
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad(
|
||||
"ESUBDIR",
|
||||
`${what} changes the whole repository; run it from a workspace at the repo root`
|
||||
);
|
||||
}
|
||||
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
}
|
||||
|
||||
/** Run a merge/cherry-pick/revert; a conflict is one that STARTED, not a failure. */
|
||||
async function startOperation(dir, action, args, branch) {
|
||||
try {
|
||||
return { ...done(action, await git(dir, args), branch), conflicts: [] };
|
||||
} catch (e) {
|
||||
if (!(await operationInProgress(dir))) throw e;
|
||||
return {
|
||||
...done(action, e.git || { stdout: "", stderr: "" }, branch),
|
||||
conflicts: await unmergedPaths(dir),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** `body.sha` as the full sha of a commit that exists, or EBADSHA. */
|
||||
async function commitSha(dir, sha) {
|
||||
if (typeof sha !== "string" || !SHA_RE.test(sha)) throw bad("EBADSHA", "sha must be a commit id");
|
||||
const full = await git(dir, ["rev-parse", "-q", "--verify", `${sha}^{commit}`]).then(
|
||||
(r) => r.stdout.trim(),
|
||||
() => ""
|
||||
);
|
||||
if (!full) throw bad("EBADSHA", `no commit ${sha}`);
|
||||
return full;
|
||||
}
|
||||
|
||||
/** How many parents a commit has (0 = root, 2+ = a merge commit). */
|
||||
async function parentCount(dir, sha) {
|
||||
return (await git(dir, ["rev-list", "--parents", "-n1", sha])).stdout.trim().split(" ").length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to rewrite HEAD unless it is still the commit the user confirmed
|
||||
* (`sha` — an agent may have committed since the dialog was drawn), it is not
|
||||
* on any remote-tracking branch, and the lane is the repo root (the rewrite
|
||||
* moves the whole branch, outside a subdirectory lane too).
|
||||
*/
|
||||
async function assertUnpushedHead(dir, sha) {
|
||||
if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "there is no commit yet");
|
||||
const seen = await commitSha(dir, sha);
|
||||
if (seen !== (await git(dir, ["rev-parse", "HEAD"])).stdout.trim()) {
|
||||
throw bad("ESTALE", "the last commit changed since you looked: refresh and retry");
|
||||
}
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad("ESUBDIR", "rewriting a commit moves the whole branch; use the repo-root workspace");
|
||||
}
|
||||
const on = (
|
||||
await git(dir, ["for-each-ref", "--contains", "HEAD", "--format=%(refname:short)", "refs/remotes"])
|
||||
).stdout.trim();
|
||||
if (on) {
|
||||
throw bad("EPUSHED", `the last commit is already on ${on.split("\n")[0]}; revert it instead`);
|
||||
}
|
||||
}
|
||||
|
||||
/** cherry-pick / revert / undo-commit / amend. */
|
||||
async function historyAction(dir, action, body) {
|
||||
if (action === "cherry-pick" || action === "revert") {
|
||||
const sha = await commitSha(dir, body.sha);
|
||||
// A merge commit needs `-m <parent>` to mean anything; picking which side
|
||||
// is a terminal decision, not a menu item.
|
||||
if ((await parentCount(dir, sha)) > 1) {
|
||||
throw bad("EMERGECOMMIT", `${action} of a merge commit is not supported here`);
|
||||
}
|
||||
await assertCleanRepoRoot(dir, `a ${action}`);
|
||||
const args = action === "revert" ? ["revert", "--no-edit", sha] : ["cherry-pick", sha];
|
||||
try {
|
||||
return { ...done(action, await git(dir, args)), conflicts: [] };
|
||||
} catch (e) {
|
||||
const open = await operationInProgress(dir);
|
||||
const conflicts = open ? await unmergedPaths(dir) : [];
|
||||
// Conflicts: an operation that STARTED, finished from the banner.
|
||||
if (conflicts.length) return { ...done(action, e.git || { stdout: "", stderr: "" }), conflicts };
|
||||
// Nothing to resolve, so nothing may be left behind either: a pick
|
||||
// whose changes are already here (EEMPTY), or a commit git could not
|
||||
// make (no identity, a failing signer) — which can leave the changes
|
||||
// staged with no *_HEAD and so no Abort. The tree was clean before, so
|
||||
// the commit's own paths go back to HEAD; nothing else is touched.
|
||||
const empty = await git(dir, ["diff", "--cached", "--quiet"]).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (open) await git(dir, [open, "--abort"]).catch(() => {});
|
||||
const touched = (
|
||||
await git(dir, ["diff-tree", "--no-commit-id", "--name-only", "-r", "-z", sha])
|
||||
).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
if (touched.length) {
|
||||
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...touched])
|
||||
.catch(() => {});
|
||||
}
|
||||
if (empty) throw bad("EEMPTY", "those changes are already on this branch");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "undo-commit") {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "undo-commit requires confirm: true");
|
||||
await assertUnpushedHead(dir, body.sha);
|
||||
const parents = await parentCount(dir, "HEAD");
|
||||
if (parents === 0) throw bad("ENOPARENT", "the first commit has nothing to go back to");
|
||||
if (parents > 1) throw bad("EMERGECOMMIT", "undoing a merge commit is not supported here");
|
||||
// --soft: only the branch moves. The commit's changes stay staged and the
|
||||
// working tree (an agent's live edits included) is never touched.
|
||||
return done(action, await git(dir, ["reset", "--soft", "HEAD~1"]));
|
||||
}
|
||||
|
||||
// amend: the index as it stands folds into the last commit, like `commit`
|
||||
// with `only: "staged"`; no message keeps the old one.
|
||||
await assertUnpushedHead(dir, body.sha);
|
||||
await assertNothingStagedOutside(dir);
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
|
||||
if (!message) {
|
||||
const staged = [...(await changedPaths(dir)).values()].some(
|
||||
({ code }) => code[0] !== " " && code[0] !== "?"
|
||||
);
|
||||
if (!staged) throw bad("ENOCHANGES", "nothing staged and no new message to amend with");
|
||||
}
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["commit", "--amend", ...(message ? ["-m", message] : ["--no-edit"])])
|
||||
);
|
||||
}
|
||||
|
||||
/** Create or delete a branch WITHOUT touching the working tree. */
|
||||
async function branchAction(dir, action, body) {
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
|
||||
@@ -82,16 +82,28 @@ function execGit(cwd, args, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The npm {@link applyUpdate} runs: the bootstrap's `npmCommand` (the running
|
||||
* node's own npm — a bare `execFile("npm.cmd")` is EINVAL on Windows since
|
||||
* Node 20.12.2) with NODE_ENV dropped. The server defaults NODE_ENV to
|
||||
* `production`, under which npm omits devDependencies — and the client's
|
||||
* vite/typescript are dev deps, so the `npm run build` after it failed.
|
||||
*/
|
||||
function npmInvocation(args, env = process.env) {
|
||||
const { NODE_ENV, ...rest } = env;
|
||||
return require("../../scripts/plugin-bootstrap").npmCommand(args, { env: rest });
|
||||
}
|
||||
|
||||
/** Same shape as {@link execGit}, for the `npm run setup`/`npm run build` steps
|
||||
* {@link applyUpdate} runs after fast-forwarding. */
|
||||
function execNpm(cwd, args, opts = {}) {
|
||||
const timeout = opts.timeout ?? 300_000;
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const npm = npmInvocation(args);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
npmCmd,
|
||||
args,
|
||||
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8" },
|
||||
npm.file,
|
||||
npm.args,
|
||||
{ cwd, timeout, maxBuffer: 5_000_000, encoding: "utf8", env: npm.env, shell: npm.shell },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) reject(new Error(stderr || err.message || String(err)));
|
||||
else resolve(String(stdout).trim());
|
||||
@@ -857,4 +869,5 @@ module.exports = {
|
||||
fetchLatestRelease,
|
||||
repoUrlFor,
|
||||
isShallowClone,
|
||||
npmInvocation,
|
||||
};
|
||||
|
||||
+50
-7
@@ -738,17 +738,58 @@ async function gitFacts(dir) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A merge in progress and how many files it still holds unmerged (repo-wide). */
|
||||
async function mergeFacts(dir) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
||||
} catch {
|
||||
return { merging: false, conflicts: 0 };
|
||||
/** The head file each history-joining operation leaves while it is open. */
|
||||
const OPERATION_HEADS = [
|
||||
["MERGE_HEAD", "merge"],
|
||||
["CHERRY_PICK_HEAD", "cherry-pick"],
|
||||
["REVERT_HEAD", "revert"],
|
||||
];
|
||||
|
||||
/** The merge, cherry-pick or revert git has open in `dir`, or null. */
|
||||
async function operationInProgress(dir) {
|
||||
for (const [ref, operation] of OPERATION_HEADS) {
|
||||
const open = await git(dir, ["rev-parse", "-q", "--verify", ref]).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (open) return operation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A merge, cherry-pick or revert in progress (`merging` stays the one flag
|
||||
* the UI gates on; `operation` names which) and how many files it still holds
|
||||
* unmerged (repo-wide).
|
||||
*/
|
||||
async function mergeFacts(dir) {
|
||||
const operation = await operationInProgress(dir);
|
||||
if (!operation) return { merging: false, operation: null, conflicts: 0 };
|
||||
const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length };
|
||||
return { merging: true, operation, conflicts: out.stdout.split("\0").filter(Boolean).length };
|
||||
}
|
||||
|
||||
/**
|
||||
* The full refname of a local or remote-tracking branch whose short name is
|
||||
* `short`, or null. Looked up, never passed through: git only ever sees a
|
||||
* ref that exists, so a request can never smuggle an option or a revision
|
||||
* expression in as a "branch".
|
||||
*/
|
||||
async function branchRef(dir, short) {
|
||||
const refs = (
|
||||
await git(dir, [
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)%00%(refname)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
])
|
||||
).stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => l.split("\0"));
|
||||
return refs.find(([s]) => s === short)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -870,6 +911,8 @@ module.exports = {
|
||||
removeWorktree,
|
||||
statusCounts,
|
||||
gitFacts,
|
||||
operationInProgress,
|
||||
branchRef,
|
||||
unpushedCount,
|
||||
hasNoRemotes,
|
||||
};
|
||||
|
||||
@@ -541,7 +541,7 @@ const paths = {
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.",
|
||||
"ENOBASE (range=branch with neither a lane base branch nor a repo default branch to compare against), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
@@ -552,16 +552,20 @@ const paths = {
|
||||
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 (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
|
||||
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH); ref= reads another existing local or remote-tracking branch's history instead of HEAD's (else 400 ENOBRANCH). 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" } },
|
||||
{ name: "ref", in: "query", schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
|
||||
400: { description: "EBADPATH (file= is absolute or has a `..` segment)." },
|
||||
400: {
|
||||
description:
|
||||
"EBADPATH (file= is absolute or has a `..` segment), ENOBRANCH (ref= is not a branch).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
@@ -734,6 +738,10 @@ const paths = {
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
"publish",
|
||||
"cherry-pick",
|
||||
"revert",
|
||||
"undo-commit",
|
||||
"amend",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -750,7 +758,16 @@ const paths = {
|
||||
description:
|
||||
"publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.",
|
||||
},
|
||||
message: { type: "string", description: "commit only; required, max 4000 chars." },
|
||||
message: {
|
||||
type: "string",
|
||||
description:
|
||||
"commit (required) and amend (optional — none keeps the old message); max 4000 chars.",
|
||||
},
|
||||
sha: {
|
||||
type: "string",
|
||||
description:
|
||||
"cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR).",
|
||||
},
|
||||
only: {
|
||||
type: "string",
|
||||
enum: ["staged"],
|
||||
|
||||
@@ -818,7 +818,7 @@ const paths = {
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (the sidebar's recently closed Terminal tabs).",
|
||||
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (a closed one's row is dropped on the next run-heal beat).",
|
||||
example: "shell",
|
||||
},
|
||||
],
|
||||
@@ -862,7 +862,7 @@ const paths = {
|
||||
tags: ["Run"],
|
||||
summary: "Forget a closed Terminal tab",
|
||||
description:
|
||||
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session — so it leaves the sidebar's recently-closed list. Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
|
||||
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session (the run-heal beat drops these rows anyway; this removes one at once). Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
|
||||
operationId: "runHistoryForget",
|
||||
parameters: [
|
||||
{
|
||||
|
||||
+24
-6
@@ -52,6 +52,7 @@ const {
|
||||
const {
|
||||
collectChanges,
|
||||
listCommits,
|
||||
defaultBranch,
|
||||
listPaths,
|
||||
listTree,
|
||||
listTreeAll,
|
||||
@@ -903,6 +904,9 @@ router.get("/:id/git", async (req, res) => {
|
||||
res.json({
|
||||
available: true,
|
||||
...(await gitFacts(t.cwd)),
|
||||
// What the Changes panel's "vs base" compares against: the lane's own
|
||||
// base, else the repo's default branch.
|
||||
base: t.lane.base_branch || (await defaultBranch(t.cwd)),
|
||||
...(t.worktree ? { worktree: t.worktree } : {}),
|
||||
});
|
||||
} catch {
|
||||
@@ -920,8 +924,8 @@ router.get("/:id/git", async (req, res) => {
|
||||
* &context=<n> — lines of context per hunk (expand)
|
||||
*
|
||||
* `work` (default) is HEAD vs the working tree, untracked files included;
|
||||
* `branch` is `<base>...HEAD`, and answers 404-free with ENOBASE when the lane
|
||||
* has no base branch to measure against; `commit` needs `&sha=` and shows that
|
||||
* `branch` is `<base>...HEAD` — the lane's base, else the repo's default
|
||||
* branch — and answers 404-free with ENOBASE when neither exists; `commit` needs `&sha=` and shows that
|
||||
* one commit. Read-only, so no same-origin guard — same reasoning as
|
||||
* GET /:id/git. See server/lib/git-diff.js for the caps.
|
||||
*/
|
||||
@@ -965,10 +969,11 @@ router.get("/:id/git/log", async (req, res) => {
|
||||
const commits = await listCommits(cwd, {
|
||||
limit: req.query.limit,
|
||||
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
||||
ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null,
|
||||
});
|
||||
res.json({ available: true, commits });
|
||||
} catch (err) {
|
||||
if (err.code === "EBADPATH") {
|
||||
if (err.code === "EBADPATH" || err.code === "ENOBRANCH") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json({ available: false, commits: [] });
|
||||
@@ -1120,8 +1125,9 @@ router.get("/:id/git/branches", async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
|
||||
* discard, commit, switch, branch-create and branch-delete.
|
||||
* The git WRITES the dashboard offers — the list is `ACTIONS` in
|
||||
* server/lib/git-ops.js (sync, stage/discard, commit/amend/undo-commit,
|
||||
* branches, merge, cherry-pick, revert).
|
||||
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
|
||||
* the worktree's git dir, so a claude and a codex lane on one directory share
|
||||
* it) so a push and a commit fired from two tabs or two lanes cannot
|
||||
@@ -1191,6 +1197,9 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
"EBADURL",
|
||||
"EREMOTEEXISTS",
|
||||
"ENOCOMMITS",
|
||||
"EBADSHA",
|
||||
"EMERGECOMMIT",
|
||||
"ENOPARENT",
|
||||
];
|
||||
// A numeric code is git's own exit status (execFile reports one that way):
|
||||
// git ran and refused — a rejected push, a failed credential, a diverged
|
||||
@@ -1200,7 +1209,16 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
// EHUNKGONE is the same kind of answer: the tree moved under the click.
|
||||
const status = badRequest.includes(err.code)
|
||||
? 400
|
||||
: exited || ["EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS"].includes(err.code)
|
||||
: exited || [
|
||||
"EHUNKGONE",
|
||||
"EGITAUTH",
|
||||
"EMERGING",
|
||||
"ENOMERGE",
|
||||
"ECONFLICTS",
|
||||
"EPUSHED",
|
||||
"EEMPTY",
|
||||
"ESTALE",
|
||||
].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
// git's own stderr is the useful half of a failed push or pull (a
|
||||
|
||||
@@ -110,8 +110,8 @@ router.get("/", (req, res) => {
|
||||
*
|
||||
* Optional ?limit=<n> caps results (default 50, max 500). The most recent
|
||||
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
|
||||
* lane, and ?tool=<id> (with a laneId) to one tool's — the sidebar reads a
|
||||
* workspace's closed Terminal tabs with `tool=shell`.
|
||||
* lane, and ?tool=<id> (with a laneId) to one tool's (`shell` = Terminal tabs;
|
||||
* a closed one's row is dropped on the next run-heal beat).
|
||||
*/
|
||||
// ── Drag & drop into the terminal ─────────────────────────────────────────
|
||||
// A local terminal answers a dropped image by typing its PATH, which is how
|
||||
@@ -219,8 +219,8 @@ router.get("/history", (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Forget one closed Terminal tab (`tool = 'shell'`) from the history — the
|
||||
* sidebar's remove. A live pane is refused; another tool's row is a 404.
|
||||
* Forget one closed Terminal tab (`tool = 'shell'`) from the history at once,
|
||||
* ahead of the run-heal beat. A live pane is refused; another tool's row is a 404.
|
||||
*/
|
||||
router.delete("/history/:id", (req, res) => {
|
||||
if (runs.listRuns().some((h) => h.id === req.params.id)) {
|
||||
|
||||
Reference in New Issue
Block a user